mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-10 20:35:17 +02:00
Merge pull request #237 from Utkarsh-Patel-13/main
Biome formatter and Linter for SurfSense Web and Extensions
This commit is contained in:
commit
eb067ee6d7
192 changed files with 27524 additions and 32966 deletions
93
.github/workflows/code-quality.yml
vendored
93
.github/workflows/code-quality.yml
vendored
|
|
@ -61,7 +61,7 @@ jobs:
|
|||
echo "Running file quality hooks on changed files against $BASE_REF"
|
||||
|
||||
# Run each hook individually on changed files
|
||||
SKIP=detect-secrets,bandit,ruff,ruff-format,prettier,eslint,typescript-check-web,typescript-check-extension,commitizen \
|
||||
SKIP=detect-secrets,bandit,ruff,ruff-format,biome-check-web,biome-check-extension,commitizen \
|
||||
pre-commit run --from-ref $BASE_REF --to-ref HEAD || exit_code=$?
|
||||
|
||||
# Exit with the same code as pre-commit
|
||||
|
|
@ -118,7 +118,7 @@ jobs:
|
|||
echo "Running security scans on changed files against $BASE_REF"
|
||||
|
||||
# Run only security hooks on changed files
|
||||
SKIP=check-yaml,check-json,check-toml,check-merge-conflict,check-added-large-files,debug-statements,check-case-conflict,ruff,ruff-format,prettier,eslint,typescript-check-web,typescript-check-extension,commitizen \
|
||||
SKIP=check-yaml,check-json,check-toml,check-merge-conflict,check-added-large-files,debug-statements,check-case-conflict,ruff,ruff-format,biome-check-web,biome-check-extension,commitizen \
|
||||
pre-commit run --from-ref $BASE_REF --to-ref HEAD || exit_code=$?
|
||||
|
||||
# Exit with the same code as pre-commit
|
||||
|
|
@ -199,7 +199,89 @@ jobs:
|
|||
echo "Running Python backend checks on changed files against $BASE_REF"
|
||||
|
||||
# Run only ruff hooks on changed Python files
|
||||
SKIP=detect-secrets,bandit,check-yaml,check-json,check-toml,check-merge-conflict,check-added-large-files,debug-statements,check-case-conflict,prettier,eslint,typescript-check-web,typescript-check-extension,commitizen \
|
||||
SKIP=detect-secrets,bandit,check-yaml,check-json,check-toml,check-merge-conflict,check-added-large-files,debug-statements,check-case-conflict,biome-check-web,biome-check-extension,commitizen \
|
||||
pre-commit run --from-ref $BASE_REF --to-ref HEAD || exit_code=$?
|
||||
|
||||
# Exit with the same code as pre-commit
|
||||
exit ${exit_code:-0}
|
||||
|
||||
typescript-frontend:
|
||||
name: TypeScript/JavaScript Quality
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch base branch
|
||||
run: |
|
||||
git fetch origin ${{ github.base_ref }}:${{ github.base_ref }} 2>/dev/null || git fetch origin ${{ github.base_ref }} 2>/dev/null || true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Check if frontend files changed
|
||||
id: frontend-changes
|
||||
uses: dorny/paths-filter@v3
|
||||
with:
|
||||
filters: |
|
||||
web:
|
||||
- 'surfsense_web/**'
|
||||
extension:
|
||||
- 'surfsense_browser_extension/**'
|
||||
|
||||
- name: Install dependencies for web
|
||||
if: steps.frontend-changes.outputs.web == 'true'
|
||||
working-directory: surfsense_web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install dependencies for extension
|
||||
if: steps.frontend-changes.outputs.extension == 'true'
|
||||
working-directory: surfsense_browser_extension
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install pre-commit
|
||||
run: pip install pre-commit
|
||||
|
||||
- name: Cache pre-commit hooks
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
key: pre-commit-frontend-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
restore-keys: |
|
||||
pre-commit-frontend-
|
||||
|
||||
- name: Install hook environments (cache)
|
||||
run: pre-commit install-hooks
|
||||
|
||||
- name: Run TypeScript/JavaScript quality checks
|
||||
run: |
|
||||
# Get base ref for comparison
|
||||
if git show-ref --verify --quiet refs/heads/${{ github.base_ref }}; then
|
||||
BASE_REF="${{ github.base_ref }}"
|
||||
elif git show-ref --verify --quiet refs/remotes/origin/${{ github.base_ref }}; then
|
||||
BASE_REF="origin/${{ github.base_ref }}"
|
||||
else
|
||||
echo "Base branch reference not found, running TypeScript/JavaScript checks on all files"
|
||||
pre-commit run --all-files biome-check-web biome-check-extension
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Running TypeScript/JavaScript checks on changed files against $BASE_REF"
|
||||
|
||||
# Run only Biome hooks on changed TypeScript/JavaScript files
|
||||
# Biome hooks use --diagnostic-level=error to only fail on errors, not warnings
|
||||
SKIP=detect-secrets,bandit,check-yaml,check-json,check-toml,check-merge-conflict,check-added-large-files,debug-statements,check-case-conflict,ruff,ruff-format,commitizen \
|
||||
pre-commit run --from-ref $BASE_REF --to-ref HEAD || exit_code=$?
|
||||
|
||||
# Exit with the same code as pre-commit
|
||||
|
|
@ -208,7 +290,7 @@ jobs:
|
|||
quality-gate:
|
||||
name: Quality Gate
|
||||
runs-on: ubuntu-latest
|
||||
needs: [file-quality, security-scan, python-backend]
|
||||
needs: [file-quality, security-scan, python-backend, typescript-frontend]
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
|
|
@ -216,7 +298,8 @@ jobs:
|
|||
run: |
|
||||
if [[ "${{ needs.file-quality.result }}" == "failure" ||
|
||||
"${{ needs.security-scan.result }}" == "failure" ||
|
||||
"${{ needs.python-backend.result }}" == "failure" ]]; then
|
||||
"${{ needs.python-backend.result }}" == "failure" ||
|
||||
"${{ needs.typescript-frontend.result }}" == "failure" ]]; then
|
||||
echo "❌ Code quality checks failed"
|
||||
exit 1
|
||||
else
|
||||
|
|
|
|||
|
|
@ -60,45 +60,28 @@ repos:
|
|||
args: ['-f', 'json', '--severity-level', 'high', '--confidence-level', 'high']
|
||||
exclude: ^surfsense_backend/(tests/|test_.*\.py|.*test.*\.py|alembic/)
|
||||
|
||||
# Frontend/Extension Hooks (TypeScript/JavaScript)
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v4.0.0-alpha.8
|
||||
hooks:
|
||||
- id: prettier
|
||||
files: ^(surfsense_web|surfsense_browser_extension)/
|
||||
types_or: [javascript, jsx, ts, tsx, json, yaml, markdown]
|
||||
exclude: '(package-lock\.json|\.next/|build/|dist/)'
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||
rev: v9.31.0
|
||||
hooks:
|
||||
- id: eslint
|
||||
files: ^surfsense_web/
|
||||
types: [file]
|
||||
types_or: [javascript, jsx, ts, tsx]
|
||||
additional_dependencies:
|
||||
- 'eslint@^9'
|
||||
- 'eslint-config-next@15.2.0'
|
||||
- '@eslint/eslintrc@^3'
|
||||
args: [--fix]
|
||||
exclude: '(\.next/|build/|dist/)'
|
||||
|
||||
# TypeScript compilation check
|
||||
# Biome hooks for TypeScript/JavaScript projects
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: typescript-check-web
|
||||
name: TypeScript Check (Web)
|
||||
entry: bash -c 'cd surfsense_web && (command -v pnpm >/dev/null 2>&1 && pnpm build --dry-run || npx next build --dry-run)'
|
||||
# Biome check for surfsense_web
|
||||
- id: biome-check-web
|
||||
name: biome-check-web
|
||||
entry: bash -c 'cd surfsense_web && npx @biomejs/biome check --diagnostic-level=error .'
|
||||
language: system
|
||||
files: ^surfsense_web/.*\.(ts|tsx)$
|
||||
files: ^surfsense_web/
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: typescript-check-extension
|
||||
name: TypeScript Check (Browser Extension)
|
||||
entry: bash -c 'cd surfsense_browser_extension && npx tsc --noEmit'
|
||||
# Biome check for surfsense_browser_extension
|
||||
- id: biome-check-extension
|
||||
name: biome-check-extension
|
||||
entry: bash -c 'cd surfsense_browser_extension && npx @biomejs/biome check --diagnostic-level=error .'
|
||||
language: system
|
||||
files: ^surfsense_browser_extension/.*\.(ts|tsx)$
|
||||
files: ^surfsense_browser_extension/
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
stages: [pre-commit]
|
||||
|
||||
# Commit message linting
|
||||
- repo: https://github.com/commitizen-tools/commitizen
|
||||
|
|
|
|||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"biome.configurationPath": "./surfsense_web/biome.json"
|
||||
}
|
||||
115
biome.json
Normal file
115
biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -76,18 +76,10 @@ async def generate_chat_podcast(
|
|||
chat_history_str += f"<user_message>{message['content']}</user_message>"
|
||||
processed_messages += 1
|
||||
elif message["role"] == "assistant":
|
||||
# Last annotation type will always be "ANSWER" here
|
||||
answer_annotation = message["annotations"][-1]
|
||||
answer_text = ""
|
||||
if answer_annotation["type"] == "ANSWER":
|
||||
answer_text = answer_annotation["content"]
|
||||
# If content is a list, join it into a single string
|
||||
if isinstance(answer_text, list):
|
||||
answer_text = "\n".join(answer_text)
|
||||
chat_history_str += (
|
||||
f"<assistant_message>{answer_text}</assistant_message>"
|
||||
)
|
||||
processed_messages += 1
|
||||
chat_history_str += (
|
||||
f"<assistant_message>{message['content']}</assistant_message>"
|
||||
)
|
||||
processed_messages += 1
|
||||
|
||||
chat_history_str += "</chat_history>"
|
||||
|
||||
|
|
|
|||
|
|
@ -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/(.*)$",
|
||||
"",
|
||||
"^~(.*)$",
|
||||
"",
|
||||
"^[./]"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,77 +1,70 @@
|
|||
import { initQueues, initWebHistory } from "~utils/commons"
|
||||
import type { WebHistory } from "~utils/interfaces"
|
||||
import { Storage } from "@plasmohq/storage"
|
||||
import {getRenderedHtml} from '~utils/commons'
|
||||
import { Storage } from "@plasmohq/storage";
|
||||
import { getRenderedHtml, initQueues, initWebHistory } from "~utils/commons";
|
||||
import type { WebHistory } from "~utils/interfaces";
|
||||
|
||||
chrome.tabs.onCreated.addListener(async (tab: any) => {
|
||||
try {
|
||||
await initWebHistory(tab.id)
|
||||
await initQueues(tab.id)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
})
|
||||
try {
|
||||
await initWebHistory(tab.id);
|
||||
await initQueues(tab.id);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener(
|
||||
async (tabId: number, changeInfo: any, tab: any) => {
|
||||
if (changeInfo.status === "complete" && tab.url) {
|
||||
const storage = new Storage({ area: "local" })
|
||||
await initWebHistory(tab.id)
|
||||
await initQueues(tab.id)
|
||||
chrome.tabs.onUpdated.addListener(async (tabId: number, changeInfo: any, tab: any) => {
|
||||
if (changeInfo.status === "complete" && tab.url) {
|
||||
const storage = new Storage({ area: "local" });
|
||||
await initWebHistory(tab.id);
|
||||
await initQueues(tab.id);
|
||||
|
||||
const result = await chrome.scripting.executeScript({
|
||||
// @ts-ignore
|
||||
target: { tabId: tab.id },
|
||||
// @ts-ignore
|
||||
func: getRenderedHtml
|
||||
})
|
||||
const result = await chrome.scripting.executeScript({
|
||||
// @ts-ignore
|
||||
target: { tabId: tab.id },
|
||||
// @ts-ignore
|
||||
func: getRenderedHtml,
|
||||
});
|
||||
|
||||
let toPushInTabHistory: any = result[0].result // const { renderedHtml, title, url, entryTime } = result[0].result;
|
||||
const toPushInTabHistory: any = result[0].result; // const { renderedHtml, title, url, entryTime } = result[0].result;
|
||||
|
||||
let urlQueueListObj: any = await storage.get("urlQueueList")
|
||||
let timeQueueListObj: any = await storage.get("timeQueueList")
|
||||
const urlQueueListObj: any = await storage.get("urlQueueList");
|
||||
const timeQueueListObj: any = await storage.get("timeQueueList");
|
||||
|
||||
urlQueueListObj.urlQueueList
|
||||
.find((data: WebHistory) => data.tabsessionId === tabId)
|
||||
.urlQueue.push(toPushInTabHistory.url)
|
||||
timeQueueListObj.timeQueueList
|
||||
.find((data: WebHistory) => data.tabsessionId === tabId)
|
||||
.timeQueue.push(toPushInTabHistory.entryTime)
|
||||
urlQueueListObj.urlQueueList
|
||||
.find((data: WebHistory) => data.tabsessionId === tabId)
|
||||
.urlQueue.push(toPushInTabHistory.url);
|
||||
timeQueueListObj.timeQueueList
|
||||
.find((data: WebHistory) => data.tabsessionId === tabId)
|
||||
.timeQueue.push(toPushInTabHistory.entryTime);
|
||||
|
||||
await storage.set("urlQueueList", {
|
||||
urlQueueList: urlQueueListObj.urlQueueList
|
||||
})
|
||||
await storage.set("timeQueueList", {
|
||||
timeQueueList: timeQueueListObj.timeQueueList
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
await storage.set("urlQueueList", {
|
||||
urlQueueList: urlQueueListObj.urlQueueList,
|
||||
});
|
||||
await storage.set("timeQueueList", {
|
||||
timeQueueList: timeQueueListObj.timeQueueList,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.onRemoved.addListener(async (tabId: number, removeInfo: object) => {
|
||||
const storage = new Storage({ area: "local" })
|
||||
let urlQueueListObj: any = await storage.get("urlQueueList")
|
||||
let timeQueueListObj: any = await storage.get("timeQueueList")
|
||||
if (urlQueueListObj.urlQueueList && timeQueueListObj.timeQueueList) {
|
||||
const urlQueueListToSave = urlQueueListObj.urlQueueList.map(
|
||||
(element: WebHistory) => {
|
||||
if (element.tabsessionId !== tabId) {
|
||||
return element
|
||||
}
|
||||
}
|
||||
)
|
||||
const timeQueueListSave = timeQueueListObj.timeQueueList.map(
|
||||
(element: WebHistory) => {
|
||||
if (element.tabsessionId !== tabId) {
|
||||
return element
|
||||
}
|
||||
}
|
||||
)
|
||||
await storage.set("urlQueueList", {
|
||||
urlQueueList: urlQueueListToSave.filter((item: any) => item)
|
||||
})
|
||||
await storage.set("timeQueueList", {
|
||||
timeQueueList: timeQueueListSave.filter((item: any) => item)
|
||||
})
|
||||
}
|
||||
})
|
||||
const storage = new Storage({ area: "local" });
|
||||
const urlQueueListObj: any = await storage.get("urlQueueList");
|
||||
const timeQueueListObj: any = await storage.get("timeQueueList");
|
||||
if (urlQueueListObj.urlQueueList && timeQueueListObj.timeQueueList) {
|
||||
const urlQueueListToSave = urlQueueListObj.urlQueueList.map((element: WebHistory) => {
|
||||
if (element.tabsessionId !== tabId) {
|
||||
return element;
|
||||
}
|
||||
});
|
||||
const timeQueueListSave = timeQueueListObj.timeQueueList.map((element: WebHistory) => {
|
||||
if (element.tabsessionId !== tabId) {
|
||||
return element;
|
||||
}
|
||||
});
|
||||
await storage.set("urlQueueList", {
|
||||
urlQueueList: urlQueueListToSave.filter((item: any) => item),
|
||||
});
|
||||
await storage.set("timeQueueList", {
|
||||
timeQueueList: timeQueueListSave.filter((item: any) => item),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,149 +1,150 @@
|
|||
import type { PlasmoMessaging } from "@plasmohq/messaging"
|
||||
import { Storage } from "@plasmohq/storage"
|
||||
import type { PlasmoMessaging } from "@plasmohq/messaging";
|
||||
import { Storage } from "@plasmohq/storage";
|
||||
|
||||
import {
|
||||
emptyArr,
|
||||
webhistoryToLangChainDocument
|
||||
} from "~utils/commons"
|
||||
import { emptyArr, webhistoryToLangChainDocument } from "~utils/commons";
|
||||
|
||||
const clearMemory = async () => {
|
||||
try {
|
||||
const storage = new Storage({ area: "local" })
|
||||
try {
|
||||
const storage = new Storage({ area: "local" });
|
||||
|
||||
let webHistory: any = await storage.get("webhistory")
|
||||
let urlQueue: any = await storage.get("urlQueueList")
|
||||
let timeQueue: any = await storage.get("timeQueueList")
|
||||
const webHistory: any = await storage.get("webhistory");
|
||||
const urlQueue: any = await storage.get("urlQueueList");
|
||||
const timeQueue: any = await storage.get("timeQueueList");
|
||||
|
||||
if (!webHistory.webhistory) {
|
||||
return
|
||||
}
|
||||
if (!webHistory.webhistory) {
|
||||
return;
|
||||
}
|
||||
|
||||
//Main Cleanup COde
|
||||
chrome.tabs.query({}, async (tabs) => {
|
||||
//Get Active Tabs Ids
|
||||
// console.log("Event Tabs",tabs)
|
||||
let actives = tabs.map((tab) => {
|
||||
if (tab.id) {
|
||||
return tab.id
|
||||
}
|
||||
})
|
||||
//Main Cleanup COde
|
||||
chrome.tabs.query({}, async (tabs) => {
|
||||
//Get Active Tabs Ids
|
||||
// console.log("Event Tabs",tabs)
|
||||
let actives = tabs.map((tab) => {
|
||||
if (tab.id) {
|
||||
return tab.id;
|
||||
}
|
||||
});
|
||||
|
||||
actives = actives.filter((item: any) => item)
|
||||
actives = actives.filter((item: any) => item);
|
||||
|
||||
//Only retain which is still active
|
||||
const newHistory = webHistory.webhistory.map((element: any) => {
|
||||
//@ts-ignore
|
||||
if (actives.includes(element.tabsessionId)) {
|
||||
return element
|
||||
}
|
||||
})
|
||||
//Only retain which is still active
|
||||
const newHistory = webHistory.webhistory.map((element: any) => {
|
||||
//@ts-ignore
|
||||
if (actives.includes(element.tabsessionId)) {
|
||||
return element;
|
||||
}
|
||||
});
|
||||
|
||||
const newUrlQueue = urlQueue.urlQueueList.map((element: any) => {
|
||||
//@ts-ignore
|
||||
if (actives.includes(element.tabsessionId)) {
|
||||
return element
|
||||
}
|
||||
})
|
||||
const newUrlQueue = urlQueue.urlQueueList.map((element: any) => {
|
||||
//@ts-ignore
|
||||
if (actives.includes(element.tabsessionId)) {
|
||||
return element;
|
||||
}
|
||||
});
|
||||
|
||||
const newTimeQueue = timeQueue.timeQueueList.map((element: any) => {
|
||||
//@ts-ignore
|
||||
if (actives.includes(element.tabsessionId)) {
|
||||
return element
|
||||
}
|
||||
})
|
||||
const newTimeQueue = timeQueue.timeQueueList.map((element: any) => {
|
||||
//@ts-ignore
|
||||
if (actives.includes(element.tabsessionId)) {
|
||||
return element;
|
||||
}
|
||||
});
|
||||
|
||||
await storage.set("webhistory", {
|
||||
webhistory: newHistory.filter((item: any) => item)
|
||||
})
|
||||
await storage.set("urlQueueList", {
|
||||
urlQueueList: newUrlQueue.filter((item: any) => item)
|
||||
})
|
||||
await storage.set("timeQueueList", {
|
||||
timeQueueList: newTimeQueue.filter((item: any) => item)
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
await storage.set("webhistory", {
|
||||
webhistory: newHistory.filter((item: any) => item),
|
||||
});
|
||||
await storage.set("urlQueueList", {
|
||||
urlQueueList: newUrlQueue.filter((item: any) => item),
|
||||
});
|
||||
await storage.set("timeQueueList", {
|
||||
timeQueueList: newTimeQueue.filter((item: any) => item),
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
|
||||
try {
|
||||
const storage = new Storage({ area: "local" })
|
||||
try {
|
||||
const storage = new Storage({ area: "local" });
|
||||
|
||||
const webhistoryObj: any = await storage.get("webhistory")
|
||||
const webhistory = webhistoryObj.webhistory
|
||||
if (webhistory) {
|
||||
let toSaveFinally: any[] = []
|
||||
let newHistoryAfterCleanup: any[] = []
|
||||
const webhistoryObj: any = await storage.get("webhistory");
|
||||
const webhistory = webhistoryObj.webhistory;
|
||||
if (webhistory) {
|
||||
const toSaveFinally: any[] = [];
|
||||
const newHistoryAfterCleanup: any[] = [];
|
||||
|
||||
for (let i = 0; i < webhistory.length; i++) {
|
||||
const markdownFormat = webhistoryToLangChainDocument(
|
||||
webhistory[i].tabsessionId,
|
||||
webhistory[i].tabHistory
|
||||
)
|
||||
toSaveFinally.push(...markdownFormat)
|
||||
newHistoryAfterCleanup.push({
|
||||
tabsessionId: webhistory[i].tabsessionId,
|
||||
tabHistory: emptyArr
|
||||
})
|
||||
}
|
||||
for (let i = 0; i < webhistory.length; i++) {
|
||||
const markdownFormat = webhistoryToLangChainDocument(
|
||||
webhistory[i].tabsessionId,
|
||||
webhistory[i].tabHistory
|
||||
);
|
||||
toSaveFinally.push(...markdownFormat);
|
||||
newHistoryAfterCleanup.push({
|
||||
tabsessionId: webhistory[i].tabsessionId,
|
||||
tabHistory: emptyArr,
|
||||
});
|
||||
}
|
||||
|
||||
await storage.set("webhistory",{ webhistory: newHistoryAfterCleanup });
|
||||
await storage.set("webhistory", { webhistory: newHistoryAfterCleanup });
|
||||
|
||||
// Log first item to debug metadata structure
|
||||
if (toSaveFinally.length > 0) {
|
||||
console.log("First item metadata:", toSaveFinally[0].metadata);
|
||||
}
|
||||
// Log first item to debug metadata structure
|
||||
if (toSaveFinally.length > 0) {
|
||||
console.log("First item metadata:", toSaveFinally[0].metadata);
|
||||
}
|
||||
|
||||
// Create content array for documents in the format expected by the new API
|
||||
const content = toSaveFinally.map(item => ({
|
||||
metadata: {
|
||||
BrowsingSessionId: String(item.metadata.BrowsingSessionId || ""),
|
||||
VisitedWebPageURL: String(item.metadata.VisitedWebPageURL || ""),
|
||||
VisitedWebPageTitle: String(item.metadata.VisitedWebPageTitle || "No Title"),
|
||||
VisitedWebPageDateWithTimeInISOString: String(item.metadata.VisitedWebPageDateWithTimeInISOString || ""),
|
||||
VisitedWebPageReffererURL: String(item.metadata.VisitedWebPageReffererURL || ""),
|
||||
VisitedWebPageVisitDurationInMilliseconds: String(item.metadata.VisitedWebPageVisitDurationInMilliseconds || "0")
|
||||
},
|
||||
pageContent: String(item.pageContent || "")
|
||||
}));
|
||||
// Create content array for documents in the format expected by the new API
|
||||
const content = toSaveFinally.map((item) => ({
|
||||
metadata: {
|
||||
BrowsingSessionId: String(item.metadata.BrowsingSessionId || ""),
|
||||
VisitedWebPageURL: String(item.metadata.VisitedWebPageURL || ""),
|
||||
VisitedWebPageTitle: String(item.metadata.VisitedWebPageTitle || "No Title"),
|
||||
VisitedWebPageDateWithTimeInISOString: String(
|
||||
item.metadata.VisitedWebPageDateWithTimeInISOString || ""
|
||||
),
|
||||
VisitedWebPageReffererURL: String(item.metadata.VisitedWebPageReffererURL || ""),
|
||||
VisitedWebPageVisitDurationInMilliseconds: String(
|
||||
item.metadata.VisitedWebPageVisitDurationInMilliseconds || "0"
|
||||
),
|
||||
},
|
||||
pageContent: String(item.pageContent || ""),
|
||||
}));
|
||||
|
||||
const token = await storage.get("token");
|
||||
const search_space_id = parseInt(await storage.get("search_space_id"), 10);
|
||||
const token = await storage.get("token");
|
||||
const search_space_id = parseInt(await storage.get("search_space_id"), 10);
|
||||
|
||||
const toSend = {
|
||||
document_type: "EXTENSION",
|
||||
content: content,
|
||||
search_space_id: search_space_id
|
||||
}
|
||||
const toSend = {
|
||||
document_type: "EXTENSION",
|
||||
content: content,
|
||||
search_space_id: search_space_id,
|
||||
};
|
||||
|
||||
console.log("toSend", toSend)
|
||||
console.log("toSend", toSend);
|
||||
|
||||
const requestOptions = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(toSend)
|
||||
}
|
||||
const requestOptions = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(toSend),
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/documents/`,
|
||||
requestOptions
|
||||
)
|
||||
const resp = await response.json()
|
||||
if (resp) {
|
||||
await clearMemory()
|
||||
res.send({
|
||||
message: "Save Job Started"
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
const response = await fetch(
|
||||
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/documents/`,
|
||||
requestOptions
|
||||
);
|
||||
const resp = await response.json();
|
||||
if (resp) {
|
||||
await clearMemory();
|
||||
res.send({
|
||||
message: "Save Job Started",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
export default handler
|
||||
export default handler;
|
||||
|
|
|
|||
|
|
@ -1,145 +1,142 @@
|
|||
import { DOMParser } from "linkedom"
|
||||
import type { PlasmoMessaging } from "@plasmohq/messaging";
|
||||
|
||||
import { Storage } from "@plasmohq/storage"
|
||||
import type { PlasmoMessaging } from "@plasmohq/messaging"
|
||||
|
||||
import type { WebHistory } from "~utils/interfaces"
|
||||
import { webhistoryToLangChainDocument, getRenderedHtml } from "~utils/commons"
|
||||
import { convertHtmlToMarkdown } from "dom-to-semantic-markdown"
|
||||
import { Storage } from "@plasmohq/storage";
|
||||
import { convertHtmlToMarkdown } from "dom-to-semantic-markdown";
|
||||
import { DOMParser } from "linkedom";
|
||||
import { getRenderedHtml, webhistoryToLangChainDocument } from "~utils/commons";
|
||||
import type { WebHistory } from "~utils/interfaces";
|
||||
|
||||
// @ts-ignore
|
||||
global.Node = {
|
||||
ELEMENT_NODE: 1,
|
||||
ATTRIBUTE_NODE: 2,
|
||||
TEXT_NODE: 3,
|
||||
CDATA_SECTION_NODE: 4,
|
||||
PROCESSING_INSTRUCTION_NODE: 7,
|
||||
COMMENT_NODE: 8,
|
||||
DOCUMENT_NODE: 9,
|
||||
DOCUMENT_TYPE_NODE: 10,
|
||||
DOCUMENT_FRAGMENT_NODE: 11,
|
||||
ELEMENT_NODE: 1,
|
||||
ATTRIBUTE_NODE: 2,
|
||||
TEXT_NODE: 3,
|
||||
CDATA_SECTION_NODE: 4,
|
||||
PROCESSING_INSTRUCTION_NODE: 7,
|
||||
COMMENT_NODE: 8,
|
||||
DOCUMENT_NODE: 9,
|
||||
DOCUMENT_TYPE_NODE: 10,
|
||||
DOCUMENT_FRAGMENT_NODE: 11,
|
||||
};
|
||||
|
||||
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
|
||||
try {
|
||||
chrome.tabs.query(
|
||||
{ active: true, currentWindow: true },
|
||||
async function (tabs) {
|
||||
const storage = new Storage({ area: "local" })
|
||||
const tab = tabs[0]
|
||||
if (tab.id) {
|
||||
const tabId: number = tab.id
|
||||
console.log("tabs", tabs)
|
||||
const result = await chrome.scripting.executeScript({
|
||||
// @ts-ignore
|
||||
target: { tabId: tab.id },
|
||||
// @ts-ignore
|
||||
func: getRenderedHtml,
|
||||
// world: "MAIN"
|
||||
})
|
||||
try {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
|
||||
const storage = new Storage({ area: "local" });
|
||||
const tab = tabs[0];
|
||||
if (tab.id) {
|
||||
const tabId: number = tab.id;
|
||||
console.log("tabs", tabs);
|
||||
const result = await chrome.scripting.executeScript({
|
||||
// @ts-ignore
|
||||
target: { tabId: tab.id },
|
||||
// @ts-ignore
|
||||
func: getRenderedHtml,
|
||||
// world: "MAIN"
|
||||
});
|
||||
|
||||
console.log("SnapRes", result)
|
||||
console.log("SnapRes", result);
|
||||
|
||||
let toPushInTabHistory: any = result[0].result // const { renderedHtml, title, url, entryTime } = result[0].result;
|
||||
const toPushInTabHistory: any = result[0].result; // const { renderedHtml, title, url, entryTime } = result[0].result;
|
||||
|
||||
toPushInTabHistory.pageContentMarkdown = convertHtmlToMarkdown(
|
||||
toPushInTabHistory.renderedHtml,
|
||||
{
|
||||
extractMainContent: true,
|
||||
enableTableColumnTracking: true,
|
||||
includeMetaData: false,
|
||||
overrideDOMParser: new DOMParser()
|
||||
}
|
||||
)
|
||||
toPushInTabHistory.pageContentMarkdown = convertHtmlToMarkdown(
|
||||
toPushInTabHistory.renderedHtml,
|
||||
{
|
||||
extractMainContent: true,
|
||||
enableTableColumnTracking: true,
|
||||
includeMetaData: false,
|
||||
overrideDOMParser: new DOMParser(),
|
||||
}
|
||||
);
|
||||
|
||||
delete toPushInTabHistory.renderedHtml
|
||||
delete toPushInTabHistory.renderedHtml;
|
||||
|
||||
console.log("toPushInTabHistory", toPushInTabHistory)
|
||||
console.log("toPushInTabHistory", toPushInTabHistory);
|
||||
|
||||
const urlQueueListObj: any = await storage.get("urlQueueList")
|
||||
const timeQueueListObj: any = await storage.get("timeQueueList")
|
||||
const urlQueueListObj: any = await storage.get("urlQueueList");
|
||||
const timeQueueListObj: any = await storage.get("timeQueueList");
|
||||
|
||||
const isUrlQueueThere = urlQueueListObj.urlQueueList.find(
|
||||
(data: WebHistory) => data.tabsessionId === tabId
|
||||
)
|
||||
const isTimeQueueThere = timeQueueListObj.timeQueueList.find(
|
||||
(data: WebHistory) => data.tabsessionId === tabId
|
||||
)
|
||||
const isUrlQueueThere = urlQueueListObj.urlQueueList.find(
|
||||
(data: WebHistory) => data.tabsessionId === tabId
|
||||
);
|
||||
const isTimeQueueThere = timeQueueListObj.timeQueueList.find(
|
||||
(data: WebHistory) => data.tabsessionId === tabId
|
||||
);
|
||||
|
||||
toPushInTabHistory.duration =
|
||||
toPushInTabHistory.entryTime -
|
||||
isTimeQueueThere.timeQueue[isTimeQueueThere.timeQueue.length - 1]
|
||||
if (isUrlQueueThere.urlQueue.length == 1) {
|
||||
toPushInTabHistory.reffererUrl = "START"
|
||||
}
|
||||
if (isUrlQueueThere.urlQueue.length > 1) {
|
||||
toPushInTabHistory.reffererUrl =
|
||||
isUrlQueueThere.urlQueue[isUrlQueueThere.urlQueue.length - 2]
|
||||
}
|
||||
toPushInTabHistory.duration =
|
||||
toPushInTabHistory.entryTime -
|
||||
isTimeQueueThere.timeQueue[isTimeQueueThere.timeQueue.length - 1];
|
||||
if (isUrlQueueThere.urlQueue.length === 1) {
|
||||
toPushInTabHistory.reffererUrl = "START";
|
||||
}
|
||||
if (isUrlQueueThere.urlQueue.length > 1) {
|
||||
toPushInTabHistory.reffererUrl =
|
||||
isUrlQueueThere.urlQueue[isUrlQueueThere.urlQueue.length - 2];
|
||||
}
|
||||
|
||||
let toSaveFinally: any[] = []
|
||||
const toSaveFinally: any[] = [];
|
||||
|
||||
const markdownFormat = webhistoryToLangChainDocument(
|
||||
tab.id,
|
||||
[toPushInTabHistory]
|
||||
)
|
||||
toSaveFinally.push(...markdownFormat)
|
||||
|
||||
console.log("toSaveFinally", toSaveFinally)
|
||||
const markdownFormat = webhistoryToLangChainDocument(tab.id, [toPushInTabHistory]);
|
||||
toSaveFinally.push(...markdownFormat);
|
||||
|
||||
// Log first item to debug metadata structure
|
||||
if (toSaveFinally.length > 0) {
|
||||
console.log("First item metadata:", toSaveFinally[0].metadata);
|
||||
}
|
||||
console.log("toSaveFinally", toSaveFinally);
|
||||
|
||||
// Create content array for documents in the format expected by the new API
|
||||
// The metadata is already in the correct format in toSaveFinally
|
||||
const content = toSaveFinally.map(item => ({
|
||||
metadata: {
|
||||
BrowsingSessionId: String(item.metadata.BrowsingSessionId || ""),
|
||||
VisitedWebPageURL: String(item.metadata.VisitedWebPageURL || ""),
|
||||
VisitedWebPageTitle: String(item.metadata.VisitedWebPageTitle || "No Title"),
|
||||
VisitedWebPageDateWithTimeInISOString: String(item.metadata.VisitedWebPageDateWithTimeInISOString || ""),
|
||||
VisitedWebPageReffererURL: String(item.metadata.VisitedWebPageReffererURL || ""),
|
||||
VisitedWebPageVisitDurationInMilliseconds: String(item.metadata.VisitedWebPageVisitDurationInMilliseconds || "0")
|
||||
},
|
||||
pageContent: String(item.pageContent || "")
|
||||
}));
|
||||
// Log first item to debug metadata structure
|
||||
if (toSaveFinally.length > 0) {
|
||||
console.log("First item metadata:", toSaveFinally[0].metadata);
|
||||
}
|
||||
|
||||
const token = await storage.get("token");
|
||||
const search_space_id = parseInt(await storage.get("search_space_id"), 10);
|
||||
// Create content array for documents in the format expected by the new API
|
||||
// The metadata is already in the correct format in toSaveFinally
|
||||
const content = toSaveFinally.map((item) => ({
|
||||
metadata: {
|
||||
BrowsingSessionId: String(item.metadata.BrowsingSessionId || ""),
|
||||
VisitedWebPageURL: String(item.metadata.VisitedWebPageURL || ""),
|
||||
VisitedWebPageTitle: String(item.metadata.VisitedWebPageTitle || "No Title"),
|
||||
VisitedWebPageDateWithTimeInISOString: String(
|
||||
item.metadata.VisitedWebPageDateWithTimeInISOString || ""
|
||||
),
|
||||
VisitedWebPageReffererURL: String(item.metadata.VisitedWebPageReffererURL || ""),
|
||||
VisitedWebPageVisitDurationInMilliseconds: String(
|
||||
item.metadata.VisitedWebPageVisitDurationInMilliseconds || "0"
|
||||
),
|
||||
},
|
||||
pageContent: String(item.pageContent || ""),
|
||||
}));
|
||||
|
||||
const toSend = {
|
||||
document_type: "EXTENSION",
|
||||
content: content,
|
||||
search_space_id: search_space_id
|
||||
}
|
||||
const token = await storage.get("token");
|
||||
const search_space_id = parseInt(await storage.get("search_space_id"), 10);
|
||||
|
||||
const requestOptions = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(toSend)
|
||||
}
|
||||
const toSend = {
|
||||
document_type: "EXTENSION",
|
||||
content: content,
|
||||
search_space_id: search_space_id,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/documents/`,
|
||||
requestOptions
|
||||
)
|
||||
const resp = await response.json()
|
||||
if (resp) {
|
||||
res.send({
|
||||
message: "Snapshot Saved Successfully"
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
const requestOptions = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(toSend),
|
||||
};
|
||||
|
||||
export default handler
|
||||
const response = await fetch(
|
||||
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/documents/`,
|
||||
requestOptions
|
||||
);
|
||||
const resp = await response.json();
|
||||
if (resp) {
|
||||
res.send({
|
||||
message: "Snapshot Saved Successfully",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
export default handler;
|
||||
|
|
|
|||
115
surfsense_browser_extension/biome.json
Normal file
115
surfsense_browser_extension/biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import type { PlasmoCSConfig } from "plasmo"
|
||||
|
||||
export const config: PlasmoCSConfig = {
|
||||
matches: ["<all_urls>"],
|
||||
all_frames: true,
|
||||
world: "MAIN"
|
||||
}
|
||||
import type { PlasmoCSConfig } from "plasmo";
|
||||
|
||||
export const config: PlasmoCSConfig = {
|
||||
matches: ["<all_urls>"],
|
||||
all_frames: true,
|
||||
world: "MAIN",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
@font-face {
|
||||
font-family: "Fascinate";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(data-base64:~assets/Fascinate.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
|
||||
U+FEFF, U+FFFD;
|
||||
}
|
||||
font-family: "Fascinate";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(data-base64:~assets/Fascinate.woff2) format("woff2");
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
|
||||
U+FEFF, U+FFFD;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,62 +1,62 @@
|
|||
{
|
||||
"name": "surfsense_browser_extension",
|
||||
"displayName": "Surfsense Browser Extension",
|
||||
"version": "0.0.7",
|
||||
"description": "Extension to collect Browsing History for SurfSense.",
|
||||
"author": "https://github.com/MODSetter",
|
||||
"scripts": {
|
||||
"dev": "plasmo dev",
|
||||
"build": "plasmo build",
|
||||
"package": "plasmo package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plasmohq/messaging": "^0.6.2",
|
||||
"@plasmohq/storage": "^1.11.0",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.3",
|
||||
"dom-to-semantic-markdown": "^1.2.11",
|
||||
"linkedom": "0.1.34",
|
||||
"lucide-react": "^0.454.0",
|
||||
"plasmo": "0.89.4",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"radix-ui": "^1.0.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hooks-global-state": "^2.1.0",
|
||||
"react-router-dom": "^6.26.1",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ianvs/prettier-plugin-sort-imports": "4.1.1",
|
||||
"@types/chrome": "0.0.258",
|
||||
"@types/node": "20.11.5",
|
||||
"@types/react": "18.2.48",
|
||||
"@types/react-dom": "18.2.18",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.41",
|
||||
"prettier": "3.2.4",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"manifest": {
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"name": "SurfSense",
|
||||
"description": "Extension to collect Browsing History for SurfSense.",
|
||||
"version": "0.0.3"
|
||||
},
|
||||
"permissions": [
|
||||
"storage",
|
||||
"scripting",
|
||||
"unlimitedStorage",
|
||||
"activeTab"
|
||||
]
|
||||
"name": "surfsense_browser_extension",
|
||||
"displayName": "Surfsense Browser Extension",
|
||||
"version": "0.0.7",
|
||||
"description": "Extension to collect Browsing History for SurfSense.",
|
||||
"author": "https://github.com/MODSetter",
|
||||
"scripts": {
|
||||
"dev": "plasmo dev",
|
||||
"build": "plasmo build",
|
||||
"package": "plasmo package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plasmohq/messaging": "^0.6.2",
|
||||
"@plasmohq/storage": "^1.11.0",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.3",
|
||||
"dom-to-semantic-markdown": "^1.2.11",
|
||||
"linkedom": "0.1.34",
|
||||
"lucide-react": "^0.454.0",
|
||||
"plasmo": "0.89.4",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"radix-ui": "^1.0.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hooks-global-state": "^2.1.0",
|
||||
"react-router-dom": "^6.26.1",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.1.2",
|
||||
"@types/chrome": "0.0.258",
|
||||
"@types/node": "20.11.5",
|
||||
"@types/react": "18.2.48",
|
||||
"@types/react-dom": "18.2.18",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.41",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"manifest": {
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"name": "SurfSense",
|
||||
"description": "Extension to collect Browsing History for SurfSense.",
|
||||
"version": "0.0.3"
|
||||
},
|
||||
"permissions": [
|
||||
"storage",
|
||||
"scripting",
|
||||
"unlimitedStorage",
|
||||
"activeTab"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
1282
surfsense_browser_extension/pnpm-lock.yaml
generated
1282
surfsense_browser_extension/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,15 +1,14 @@
|
|||
import { MemoryRouter } from "react-router-dom"
|
||||
|
||||
import { Routing } from "~routes"
|
||||
import { Toaster } from "@/routes/ui/toaster"
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Toaster } from "@/routes/ui/toaster";
|
||||
import { Routing } from "~routes";
|
||||
|
||||
function IndexPopup() {
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<Routing />
|
||||
<Toaster />
|
||||
</MemoryRouter>
|
||||
)
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<Routing />
|
||||
<Toaster />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default IndexPopup
|
||||
export default IndexPopup;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import { Route, Routes } from "react-router-dom"
|
||||
|
||||
import ApiKeyForm from "./pages/ApiKeyForm"
|
||||
import HomePage from "./pages/HomePage"
|
||||
import '../tailwind.css'
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
|
||||
import ApiKeyForm from "./pages/ApiKeyForm";
|
||||
import HomePage from "./pages/HomePage";
|
||||
import "../tailwind.css";
|
||||
|
||||
export const Routing = () => (
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/login" element={<ApiKeyForm />} />
|
||||
</Routes>
|
||||
)
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/login" element={<ApiKeyForm />} />
|
||||
</Routes>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,123 +1,122 @@
|
|||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import icon from "data-base64:~assets/icon.png"
|
||||
import { Storage } from "@plasmohq/storage"
|
||||
import { Button } from "~/routes/ui/button"
|
||||
import { ReloadIcon } from "@radix-ui/react-icons"
|
||||
import icon from "data-base64:~assets/icon.png";
|
||||
import { Storage } from "@plasmohq/storage";
|
||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "~/routes/ui/button";
|
||||
|
||||
const ApiKeyForm = () => {
|
||||
const navigation = useNavigate()
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const storage = new Storage({ area: "local" })
|
||||
const navigation = useNavigate();
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const storage = new Storage({ area: "local" });
|
||||
|
||||
const validateForm = () => {
|
||||
if (!apiKey) {
|
||||
setError('API key is required');
|
||||
return false;
|
||||
}
|
||||
setError('');
|
||||
return true;
|
||||
};
|
||||
const validateForm = () => {
|
||||
if (!apiKey) {
|
||||
setError("API key is required");
|
||||
return false;
|
||||
}
|
||||
setError("");
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: { preventDefault: () => void; }) => {
|
||||
event.preventDefault();
|
||||
if (!validateForm()) return;
|
||||
setLoading(true);
|
||||
const handleSubmit = async (event: { preventDefault: () => void }) => {
|
||||
event.preventDefault();
|
||||
if (!validateForm()) return;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Verify token is valid by making a request to the API
|
||||
const response = await fetch(`${process.env.PLASMO_PUBLIC_BACKEND_URL}/verify-token`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
}
|
||||
});
|
||||
try {
|
||||
// Verify token is valid by making a request to the API
|
||||
const response = await fetch(`${process.env.PLASMO_PUBLIC_BACKEND_URL}/verify-token`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
setLoading(false);
|
||||
|
||||
if (response.ok) {
|
||||
// Store the API key as the token
|
||||
await storage.set('token', apiKey);
|
||||
navigation("/")
|
||||
} else {
|
||||
setError('Invalid API key. Please check and try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
setError('An error occurred. Please try again later.');
|
||||
}
|
||||
};
|
||||
if (response.ok) {
|
||||
// Store the API key as the token
|
||||
await storage.set("token", apiKey);
|
||||
navigation("/");
|
||||
} else {
|
||||
setError("Invalid API key. Please check and try again.");
|
||||
}
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
setError("An error occurred. Please try again later.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 flex flex-col items-center justify-center p-6">
|
||||
<div className="w-full max-w-md mx-auto space-y-8">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="bg-gray-800 p-3 rounded-full ring-2 ring-gray-700 shadow-lg">
|
||||
<img className="w-12 h-12" src={icon} alt="SurfSense" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-white mt-4">SurfSense</h1>
|
||||
</div>
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 flex flex-col items-center justify-center p-6">
|
||||
<div className="w-full max-w-md mx-auto space-y-8">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="bg-gray-800 p-3 rounded-full ring-2 ring-gray-700 shadow-lg">
|
||||
<img className="w-12 h-12" src={icon} alt="SurfSense" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-white mt-4">SurfSense</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800/70 backdrop-blur-sm rounded-xl shadow-xl border border-gray-700 p-6">
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-medium text-white">Enter your API Key</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Your API key connects this extension to the SurfSense.
|
||||
</p>
|
||||
<div className="bg-gray-800/70 backdrop-blur-sm rounded-xl shadow-xl border border-gray-700 p-6">
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-medium text-white">Enter your API Key</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Your API key connects this extension to the SurfSense.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="apiKey" className="text-sm font-medium text-gray-300">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="apiKey"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-gray-900/50 border border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-teal-500 text-white placeholder:text-gray-500"
|
||||
placeholder="Enter your API key"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm mt-1">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="apiKey" className="text-sm font-medium text-gray-300">
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="apiKey"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-gray-900/50 border border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-teal-500 text-white placeholder:text-gray-500"
|
||||
placeholder="Enter your API key"
|
||||
/>
|
||||
{error && <p className="text-red-400 text-sm mt-1">{error}</p>}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-teal-600 hover:bg-teal-500 text-white py-2 px-4 rounded-md transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-teal-600 hover:bg-teal-500 text-white py-2 px-4 rounded-md transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="text-center mt-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
Need an API key?{" "}
|
||||
<a
|
||||
href="https://www.surfsense.net"
|
||||
target="_blank"
|
||||
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||
>
|
||||
Sign up
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<div className="text-center mt-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
Need an API key?{" "}
|
||||
<a
|
||||
href="https://www.surfsense.net"
|
||||
target="_blank"
|
||||
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||
rel="noopener"
|
||||
>
|
||||
Sign up
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeyForm
|
||||
export default ApiKeyForm;
|
||||
|
|
|
|||
|
|
@ -1,476 +1,478 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import icon from "data-base64:~assets/icon.png"
|
||||
import brain from "data-base64:~assets/brain.png";
|
||||
import icon from "data-base64:~assets/icon.png";
|
||||
import { sendToBackground } from "@plasmohq/messaging";
|
||||
import { Storage } from "@plasmohq/storage";
|
||||
import {
|
||||
CrossCircledIcon,
|
||||
DiscIcon,
|
||||
ExitIcon,
|
||||
FileIcon,
|
||||
ReloadIcon,
|
||||
UploadIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { convertHtmlToMarkdown } from "dom-to-semantic-markdown";
|
||||
import type { WebHistory } from "~utils/interfaces";
|
||||
import { getRenderedHtml } from "~utils/commons";
|
||||
import Loading from "./Loading";
|
||||
import brain from "data-base64:~assets/brain.png"
|
||||
import { Storage } from "@plasmohq/storage"
|
||||
import { sendToBackground } from "@plasmohq/messaging"
|
||||
import { Check, ChevronsUpDown } from "lucide-react"
|
||||
import { cn } from "~/lib/utils"
|
||||
import { Button } from "~/routes/ui/button"
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/routes/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "~/routes/ui/command"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/routes/ui/popover"
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "~/routes/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/routes/ui/popover";
|
||||
import { Label } from "~routes/ui/label";
|
||||
import { useToast } from "~routes/ui/use-toast";
|
||||
import {
|
||||
CircleIcon,
|
||||
CrossCircledIcon,
|
||||
DiscIcon,
|
||||
ExitIcon,
|
||||
FileIcon,
|
||||
ReloadIcon,
|
||||
ResetIcon,
|
||||
UploadIcon
|
||||
} from "@radix-ui/react-icons"
|
||||
import { getRenderedHtml } from "~utils/commons";
|
||||
import type { WebHistory } from "~utils/interfaces";
|
||||
import Loading from "./Loading";
|
||||
|
||||
const HomePage = () => {
|
||||
const { toast } = useToast()
|
||||
const navigation = useNavigate()
|
||||
const [noOfWebPages, setNoOfWebPages] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [value, setValue] = React.useState<string>("")
|
||||
const [searchspaces, setSearchSpaces] = useState([])
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const navigation = useNavigate();
|
||||
const [noOfWebPages, setNoOfWebPages] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [value, setValue] = React.useState<string>("");
|
||||
const [searchspaces, setSearchSpaces] = useState([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSearchSpaces = async () => {
|
||||
const storage = new Storage({ area: "local" })
|
||||
const token = await storage.get('token');
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/searchspaces/`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
useEffect(() => {
|
||||
const checkSearchSpaces = async () => {
|
||||
const storage = new Storage({ area: "local" });
|
||||
const token = await storage.get("token");
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/searchspaces/`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Token verification failed");
|
||||
} else {
|
||||
const res = await response.json()
|
||||
console.log(res)
|
||||
setSearchSpaces(res)
|
||||
}
|
||||
} catch (error) {
|
||||
await storage.remove('token');
|
||||
await storage.remove('showShadowDom');
|
||||
navigation("/login")
|
||||
}
|
||||
};
|
||||
if (!response.ok) {
|
||||
throw new Error("Token verification failed");
|
||||
} else {
|
||||
const res = await response.json();
|
||||
console.log(res);
|
||||
setSearchSpaces(res);
|
||||
}
|
||||
} catch (error) {
|
||||
await storage.remove("token");
|
||||
await storage.remove("showShadowDom");
|
||||
navigation("/login");
|
||||
}
|
||||
};
|
||||
|
||||
checkSearchSpaces();
|
||||
setLoading(false);
|
||||
}, []);
|
||||
checkSearchSpaces();
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function onLoad() {
|
||||
try {
|
||||
chrome.storage.onChanged.addListener((changes: any, areaName: string) => {
|
||||
if (changes.webhistory) {
|
||||
const webhistory = JSON.parse(changes.webhistory.newValue);
|
||||
console.log("webhistory", webhistory);
|
||||
|
||||
useEffect(() => {
|
||||
async function onLoad() {
|
||||
try {
|
||||
chrome.storage.onChanged.addListener(
|
||||
(changes: any, areaName: string) => {
|
||||
if (changes.webhistory) {
|
||||
const webhistory = JSON.parse(changes.webhistory.newValue);
|
||||
console.log("webhistory", webhistory)
|
||||
let sum = 0;
|
||||
webhistory.webhistory.forEach((element: any) => {
|
||||
sum = sum + element.tabHistory.length;
|
||||
});
|
||||
|
||||
let sum = 0
|
||||
webhistory.webhistory.forEach((element: any) => {
|
||||
sum = sum + element.tabHistory.length
|
||||
});
|
||||
setNoOfWebPages(sum);
|
||||
}
|
||||
});
|
||||
|
||||
setNoOfWebPages(sum)
|
||||
}
|
||||
}
|
||||
);
|
||||
const storage = new Storage({ area: "local" });
|
||||
const searchspace = await storage.get("search_space");
|
||||
|
||||
const storage = new Storage({ area: "local" })
|
||||
const searchspace = await storage.get("search_space");
|
||||
if (searchspace) {
|
||||
setValue(searchspace);
|
||||
}
|
||||
|
||||
if(searchspace){
|
||||
setValue(searchspace)
|
||||
}
|
||||
await storage.set("showShadowDom", true);
|
||||
|
||||
await storage.set("showShadowDom", true)
|
||||
const webhistoryObj: any = await storage.get("webhistory");
|
||||
if (webhistoryObj.webhistory.length) {
|
||||
const webhistory = webhistoryObj.webhistory;
|
||||
|
||||
const webhistoryObj: any = await storage.get("webhistory");
|
||||
if (webhistoryObj.webhistory.length) {
|
||||
const webhistory = webhistoryObj.webhistory;
|
||||
if (webhistoryObj) {
|
||||
let sum = 0;
|
||||
webhistory.forEach((element: any) => {
|
||||
sum = sum + element.tabHistory.length;
|
||||
});
|
||||
setNoOfWebPages(sum);
|
||||
}
|
||||
} else {
|
||||
setNoOfWebPages(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (webhistoryObj) {
|
||||
let sum = 0
|
||||
webhistory.forEach((element: any) => {
|
||||
sum = sum + element.tabHistory.length
|
||||
});
|
||||
setNoOfWebPages(sum)
|
||||
}
|
||||
} else {
|
||||
setNoOfWebPages(0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
onLoad();
|
||||
}, []);
|
||||
|
||||
onLoad()
|
||||
}, []);
|
||||
async function clearMem(): Promise<void> {
|
||||
try {
|
||||
const storage = new Storage({ area: "local" });
|
||||
|
||||
async function clearMem(): Promise<void> {
|
||||
try {
|
||||
const storage = new Storage({ area: "local" })
|
||||
|
||||
let webHistory: any = await storage.get("webhistory");
|
||||
let urlQueue: any = await storage.get("urlQueueList");
|
||||
let timeQueue: any = await storage.get("timeQueueList");
|
||||
|
||||
if (!webHistory.webhistory) {
|
||||
return
|
||||
}
|
||||
|
||||
//Main Cleanup COde
|
||||
chrome.tabs.query({}, async (tabs) => {
|
||||
//Get Active Tabs Ids
|
||||
let actives = tabs.map((tab) => {
|
||||
if (tab.id) {
|
||||
return tab.id
|
||||
}
|
||||
})
|
||||
|
||||
actives = actives.filter((item: any) => item)
|
||||
|
||||
//Only retain which is still active
|
||||
const newHistory = webHistory.webhistory.map((element: any) => {
|
||||
//@ts-ignore
|
||||
if (actives.includes(element.tabsessionId)) {
|
||||
return element
|
||||
}
|
||||
})
|
||||
|
||||
const newUrlQueue = urlQueue.urlQueueList.map((element: any) => {
|
||||
//@ts-ignore
|
||||
if (actives.includes(element.tabsessionId)) {
|
||||
return element
|
||||
}
|
||||
})
|
||||
|
||||
const newTimeQueue = timeQueue.timeQueueList.map((element: any) => {
|
||||
//@ts-ignore
|
||||
if (actives.includes(element.tabsessionId)) {
|
||||
return element
|
||||
}
|
||||
})
|
||||
|
||||
await storage.set("webhistory", { webhistory: newHistory.filter((item: any) => item) });
|
||||
await storage.set("urlQueueList", { urlQueueList: newUrlQueue.filter((item: any) => item) });
|
||||
await storage.set("timeQueueList", { timeQueueList: newTimeQueue.filter((item: any) => item) });
|
||||
toast({
|
||||
title: "History store cleared",
|
||||
description: "Inactive history sessions have been removed",
|
||||
variant: "destructive",
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
const webHistory: any = await storage.get("webhistory");
|
||||
const urlQueue: any = await storage.get("urlQueueList");
|
||||
const timeQueue: any = await storage.get("timeQueueList");
|
||||
|
||||
async function saveCurrSnapShot(): Promise<void> {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, async function (tabs) {
|
||||
const storage = new Storage({ area: "local" })
|
||||
const tab = tabs[0];
|
||||
if (tab.id) {
|
||||
const tabId: number = tab.id
|
||||
const result = await chrome.scripting.executeScript({
|
||||
// @ts-ignore
|
||||
target: { tabId: tab.id },
|
||||
// @ts-ignore
|
||||
func: getRenderedHtml,
|
||||
});
|
||||
if (!webHistory.webhistory) {
|
||||
return;
|
||||
}
|
||||
|
||||
let toPushInTabHistory: any = result[0].result;
|
||||
//Main Cleanup COde
|
||||
chrome.tabs.query({}, async (tabs) => {
|
||||
//Get Active Tabs Ids
|
||||
let actives = tabs.map((tab) => {
|
||||
if (tab.id) {
|
||||
return tab.id;
|
||||
}
|
||||
});
|
||||
|
||||
//Updates 'tabhistory'
|
||||
let webhistoryObj: any = await storage.get("webhistory");
|
||||
actives = actives.filter((item: any) => item);
|
||||
|
||||
const webHistoryOfTabId = webhistoryObj.webhistory.filter(
|
||||
(data: WebHistory) => {
|
||||
return data.tabsessionId === tab.id;
|
||||
}
|
||||
);
|
||||
//Only retain which is still active
|
||||
const newHistory = webHistory.webhistory.map((element: any) => {
|
||||
//@ts-ignore
|
||||
if (actives.includes(element.tabsessionId)) {
|
||||
return element;
|
||||
}
|
||||
});
|
||||
|
||||
toPushInTabHistory.pageContentMarkdown = convertHtmlToMarkdown(
|
||||
toPushInTabHistory.renderedHtml,
|
||||
{
|
||||
extractMainContent: true,
|
||||
includeMetaData: false,
|
||||
enableTableColumnTracking: true
|
||||
}
|
||||
)
|
||||
const newUrlQueue = urlQueue.urlQueueList.map((element: any) => {
|
||||
//@ts-ignore
|
||||
if (actives.includes(element.tabsessionId)) {
|
||||
return element;
|
||||
}
|
||||
});
|
||||
|
||||
delete toPushInTabHistory.renderedHtml
|
||||
const newTimeQueue = timeQueue.timeQueueList.map((element: any) => {
|
||||
//@ts-ignore
|
||||
if (actives.includes(element.tabsessionId)) {
|
||||
return element;
|
||||
}
|
||||
});
|
||||
|
||||
let tabhistory = webHistoryOfTabId[0].tabHistory;
|
||||
await storage.set("webhistory", { webhistory: newHistory.filter((item: any) => item) });
|
||||
await storage.set("urlQueueList", {
|
||||
urlQueueList: newUrlQueue.filter((item: any) => item),
|
||||
});
|
||||
await storage.set("timeQueueList", {
|
||||
timeQueueList: newTimeQueue.filter((item: any) => item),
|
||||
});
|
||||
toast({
|
||||
title: "History store cleared",
|
||||
description: "Inactive history sessions have been removed",
|
||||
variant: "destructive",
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
const urlQueueListObj: any = await storage.get("urlQueueList");
|
||||
const timeQueueListObj: any = await storage.get("timeQueueList");
|
||||
async function saveCurrSnapShot(): Promise<void> {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
|
||||
const storage = new Storage({ area: "local" });
|
||||
const tab = tabs[0];
|
||||
if (tab.id) {
|
||||
const tabId: number = tab.id;
|
||||
const result = await chrome.scripting.executeScript({
|
||||
// @ts-ignore
|
||||
target: { tabId: tab.id },
|
||||
// @ts-ignore
|
||||
func: getRenderedHtml,
|
||||
});
|
||||
|
||||
const isUrlQueueThere = urlQueueListObj.urlQueueList.find((data: WebHistory) => data.tabsessionId === tabId)
|
||||
const isTimeQueueThere = timeQueueListObj.timeQueueList.find((data: WebHistory) => data.tabsessionId === tabId)
|
||||
const toPushInTabHistory: any = result[0].result;
|
||||
|
||||
toPushInTabHistory.duration = toPushInTabHistory.entryTime - isTimeQueueThere.timeQueue[isTimeQueueThere.timeQueue.length - 1]
|
||||
if (isUrlQueueThere.urlQueue.length == 1) {
|
||||
toPushInTabHistory.reffererUrl = 'START'
|
||||
}
|
||||
if (isUrlQueueThere.urlQueue.length > 1) {
|
||||
toPushInTabHistory.reffererUrl = isUrlQueueThere.urlQueue[isUrlQueueThere.urlQueue.length - 2];
|
||||
}
|
||||
//Updates 'tabhistory'
|
||||
const webhistoryObj: any = await storage.get("webhistory");
|
||||
|
||||
webHistoryOfTabId[0].tabHistory.push(toPushInTabHistory);
|
||||
|
||||
await storage.set("webhistory", webhistoryObj);
|
||||
|
||||
toast({
|
||||
title: "Snapshot saved",
|
||||
description: `Captured: ${toPushInTabHistory.title}`,
|
||||
})
|
||||
}
|
||||
const webHistoryOfTabId = webhistoryObj.webhistory.filter((data: WebHistory) => {
|
||||
return data.tabsessionId === tab.id;
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
toPushInTabHistory.pageContentMarkdown = convertHtmlToMarkdown(
|
||||
toPushInTabHistory.renderedHtml,
|
||||
{
|
||||
extractMainContent: true,
|
||||
includeMetaData: false,
|
||||
enableTableColumnTracking: true,
|
||||
}
|
||||
);
|
||||
|
||||
const saveDatamessage = async () => {
|
||||
if (value === "") {
|
||||
toast({
|
||||
title: "Select a SearchSpace !",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const storage = new Storage({ area: "local" })
|
||||
const search_space_id = await storage.get("search_space_id");
|
||||
|
||||
if (!search_space_id) {
|
||||
toast({
|
||||
title: "Invalid SearchSpace selected!",
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
delete toPushInTabHistory.renderedHtml;
|
||||
|
||||
setIsSaving(true);
|
||||
toast({
|
||||
title: "Save job running",
|
||||
description: "Saving captured content to SurfSense",
|
||||
})
|
||||
const tabhistory = webHistoryOfTabId[0].tabHistory;
|
||||
|
||||
try {
|
||||
const resp = await sendToBackground({
|
||||
// @ts-ignore
|
||||
name: "savedata",
|
||||
})
|
||||
const urlQueueListObj: any = await storage.get("urlQueueList");
|
||||
const timeQueueListObj: any = await storage.get("timeQueueList");
|
||||
|
||||
toast({
|
||||
title: resp.message,
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error saving data",
|
||||
description: "Please try again",
|
||||
variant: "destructive",
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
const isUrlQueueThere = urlQueueListObj.urlQueueList.find(
|
||||
(data: WebHistory) => data.tabsessionId === tabId
|
||||
);
|
||||
const isTimeQueueThere = timeQueueListObj.timeQueueList.find(
|
||||
(data: WebHistory) => data.tabsessionId === tabId
|
||||
);
|
||||
|
||||
async function logOut(): Promise<void> {
|
||||
const storage = new Storage({ area: "local" })
|
||||
await storage.remove('token');
|
||||
await storage.remove('showShadowDom');
|
||||
navigation("/login")
|
||||
}
|
||||
toPushInTabHistory.duration =
|
||||
toPushInTabHistory.entryTime -
|
||||
isTimeQueueThere.timeQueue[isTimeQueueThere.timeQueue.length - 1];
|
||||
if (isUrlQueueThere.urlQueue.length === 1) {
|
||||
toPushInTabHistory.reffererUrl = "START";
|
||||
}
|
||||
if (isUrlQueueThere.urlQueue.length > 1) {
|
||||
toPushInTabHistory.reffererUrl =
|
||||
isUrlQueueThere.urlQueue[isUrlQueueThere.urlQueue.length - 2];
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
} else {
|
||||
return searchspaces.length === 0 ? (
|
||||
<div className="flex min-h-screen flex-col bg-gradient-to-br from-gray-900 to-gray-800">
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="flex flex-col items-center space-y-2 text-center">
|
||||
<div className="rounded-full bg-gray-800 p-3 shadow-lg ring-2 ring-gray-700">
|
||||
<img className="h-12 w-12" src={icon} alt="SurfSense" />
|
||||
</div>
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-white">SurfSense</h1>
|
||||
<div className="mt-4 rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-4 text-yellow-300">
|
||||
<p className="text-sm">Please create a Search Space to continue</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Button
|
||||
onClick={logOut}
|
||||
variant="outline"
|
||||
className="flex items-center space-x-2 border-gray-700 bg-gray-800 text-gray-200 hover:bg-gray-700"
|
||||
>
|
||||
<ExitIcon className="h-4 w-4" />
|
||||
<span>Sign Out</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-screen flex-col bg-gradient-to-br from-gray-900 to-gray-800">
|
||||
<div className="container mx-auto max-w-md p-4">
|
||||
<div className="flex items-center justify-between border-b border-gray-700 pb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-full bg-gray-800 p-2 shadow-md ring-1 ring-gray-700">
|
||||
<img className="h-6 w-6" src={icon} alt="SurfSense" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-white">SurfSense</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={logOut}
|
||||
className="rounded-full text-gray-400 hover:bg-gray-800 hover:text-white"
|
||||
>
|
||||
<ExitIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Log out</span>
|
||||
</Button>
|
||||
</div>
|
||||
webHistoryOfTabId[0].tabHistory.push(toPushInTabHistory);
|
||||
|
||||
<div className="space-y-3 py-4">
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-gray-700 bg-gray-800/50 p-6 backdrop-blur-sm">
|
||||
<div className="flex h-28 w-28 items-center justify-center rounded-full bg-gradient-to-br from-gray-700 to-gray-800 shadow-inner">
|
||||
<div className="flex flex-col items-center">
|
||||
<img className="mb-2 h-10 w-10 opacity-80" src={brain} alt="brain" />
|
||||
<span className="text-2xl font-semibold text-white">{noOfWebPages}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-400">Captured web pages</p>
|
||||
</div>
|
||||
await storage.set("webhistory", webhistoryObj);
|
||||
|
||||
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-4 backdrop-blur-sm">
|
||||
<label className="mb-2 block text-sm font-medium text-gray-300">
|
||||
Search Space
|
||||
</label>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between border-gray-700 bg-gray-900 text-white hover:bg-gray-700"
|
||||
>
|
||||
{value
|
||||
? searchspaces.find((space) => space.name === value)?.name
|
||||
: "Select Search Space..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full border-gray-700 bg-gray-800/90 p-0 backdrop-blur-sm">
|
||||
<Command className="bg-transparent">
|
||||
<CommandInput placeholder="Search spaces..." className="border-gray-700 bg-gray-900 text-gray-200" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No search spaces found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{searchspaces.map((space) => (
|
||||
<CommandItem
|
||||
key={space.name}
|
||||
value={space.name}
|
||||
onSelect={async (currentValue) => {
|
||||
const storage = new Storage({ area: "local" })
|
||||
if (currentValue === value) {
|
||||
await storage.set("search_space", "");
|
||||
await storage.set("search_space_id", 0);
|
||||
} else {
|
||||
const selectedSpace = searchspaces.find((space) => space.name === currentValue);
|
||||
await storage.set("search_space", currentValue);
|
||||
await storage.set("search_space_id", selectedSpace.id);
|
||||
}
|
||||
setValue(currentValue === value ? "" : currentValue)
|
||||
setOpen(false)
|
||||
}}
|
||||
className="aria-selected:bg-gray-700"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === space.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<DiscIcon className="mr-2 h-4 w-4 text-teal-400" />
|
||||
{space.name}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
toast({
|
||||
title: "Snapshot saved",
|
||||
description: `Captured: ${toPushInTabHistory.title}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
<div className="grid gap-3">
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="group flex w-full items-center justify-center space-x-2 bg-red-500/90 text-white hover:bg-red-600"
|
||||
onClick={() => clearMem()}
|
||||
>
|
||||
<CrossCircledIcon className="h-4 w-4 transition-transform group-hover:scale-110" />
|
||||
<span>Clear Inactive History</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="group flex w-full items-center justify-center space-x-2 border-amber-500/50 bg-amber-500/10 text-amber-200 hover:bg-amber-500/20"
|
||||
onClick={() => saveCurrSnapShot()}
|
||||
>
|
||||
<FileIcon className="h-4 w-4 transition-transform group-hover:scale-110" />
|
||||
<span>Save Current Page</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
className="group flex w-full items-center justify-center space-x-2 bg-gradient-to-r from-teal-500 to-emerald-500 text-white transition-all hover:from-teal-600 hover:to-emerald-600"
|
||||
onClick={() => saveDatamessage()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span>Saving to SurfSense...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadIcon className="h-4 w-4 transition-transform group-hover:scale-110" />
|
||||
<span>Save to SurfSense</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const saveDatamessage = async () => {
|
||||
if (value === "") {
|
||||
toast({
|
||||
title: "Select a SearchSpace !",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = new Storage({ area: "local" });
|
||||
const search_space_id = await storage.get("search_space_id");
|
||||
|
||||
if (!search_space_id) {
|
||||
toast({
|
||||
title: "Invalid SearchSpace selected!",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
toast({
|
||||
title: "Save job running",
|
||||
description: "Saving captured content to SurfSense",
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await sendToBackground({
|
||||
// @ts-ignore
|
||||
name: "savedata",
|
||||
});
|
||||
|
||||
toast({
|
||||
title: resp.message,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error saving data",
|
||||
description: "Please try again",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
async function logOut(): Promise<void> {
|
||||
const storage = new Storage({ area: "local" });
|
||||
await storage.remove("token");
|
||||
await storage.remove("showShadowDom");
|
||||
navigation("/login");
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
} else {
|
||||
return searchspaces.length === 0 ? (
|
||||
<div className="flex min-h-screen flex-col bg-gradient-to-br from-gray-900 to-gray-800">
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="flex flex-col items-center space-y-2 text-center">
|
||||
<div className="rounded-full bg-gray-800 p-3 shadow-lg ring-2 ring-gray-700">
|
||||
<img className="h-12 w-12" src={icon} alt="SurfSense" />
|
||||
</div>
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-white">SurfSense</h1>
|
||||
<div className="mt-4 rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-4 text-yellow-300">
|
||||
<p className="text-sm">Please create a Search Space to continue</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Button
|
||||
onClick={logOut}
|
||||
variant="outline"
|
||||
className="flex items-center space-x-2 border-gray-700 bg-gray-800 text-gray-200 hover:bg-gray-700"
|
||||
>
|
||||
<ExitIcon className="h-4 w-4" />
|
||||
<span>Sign Out</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-screen flex-col bg-gradient-to-br from-gray-900 to-gray-800">
|
||||
<div className="container mx-auto max-w-md p-4">
|
||||
<div className="flex items-center justify-between border-b border-gray-700 pb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="rounded-full bg-gray-800 p-2 shadow-md ring-1 ring-gray-700">
|
||||
<img className="h-6 w-6" src={icon} alt="SurfSense" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-white">SurfSense</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={logOut}
|
||||
className="rounded-full text-gray-400 hover:bg-gray-800 hover:text-white"
|
||||
>
|
||||
<ExitIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Log out</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 py-4">
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-gray-700 bg-gray-800/50 p-6 backdrop-blur-sm">
|
||||
<div className="flex h-28 w-28 items-center justify-center rounded-full bg-gradient-to-br from-gray-700 to-gray-800 shadow-inner">
|
||||
<div className="flex flex-col items-center">
|
||||
<img className="mb-2 h-10 w-10 opacity-80" src={brain} alt="brain" />
|
||||
<span className="text-2xl font-semibold text-white">{noOfWebPages}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-400">Captured web pages</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-4 backdrop-blur-sm">
|
||||
<Label className="mb-2 block text-sm font-medium text-gray-300">Search Space</Label>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between border-gray-700 bg-gray-900 text-white hover:bg-gray-700"
|
||||
>
|
||||
{value
|
||||
? searchspaces.find((space) => space.name === value)?.name
|
||||
: "Select Search Space..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full border-gray-700 bg-gray-800/90 p-0 backdrop-blur-sm">
|
||||
<Command className="bg-transparent">
|
||||
<CommandInput
|
||||
placeholder="Search spaces..."
|
||||
className="border-gray-700 bg-gray-900 text-gray-200"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No search spaces found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{searchspaces.map((space) => (
|
||||
<CommandItem
|
||||
key={space.name}
|
||||
value={space.name}
|
||||
onSelect={async (currentValue) => {
|
||||
const storage = new Storage({ area: "local" });
|
||||
if (currentValue === value) {
|
||||
await storage.set("search_space", "");
|
||||
await storage.set("search_space_id", 0);
|
||||
} else {
|
||||
const selectedSpace = searchspaces.find(
|
||||
(space) => space.name === currentValue
|
||||
);
|
||||
await storage.set("search_space", currentValue);
|
||||
await storage.set("search_space_id", selectedSpace.id);
|
||||
}
|
||||
setValue(currentValue === value ? "" : currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="aria-selected:bg-gray-700"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === space.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<DiscIcon className="mr-2 h-4 w-4 text-teal-400" />
|
||||
{space.name}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="group flex w-full items-center justify-center space-x-2 bg-red-500/90 text-white hover:bg-red-600"
|
||||
onClick={() => clearMem()}
|
||||
>
|
||||
<CrossCircledIcon className="h-4 w-4 transition-transform group-hover:scale-110" />
|
||||
<span>Clear Inactive History</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="group flex w-full items-center justify-center space-x-2 border-amber-500/50 bg-amber-500/10 text-amber-200 hover:bg-amber-500/20"
|
||||
onClick={() => saveCurrSnapShot()}
|
||||
>
|
||||
<FileIcon className="h-4 w-4 transition-transform group-hover:scale-110" />
|
||||
<span>Save Current Page</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
className="group flex w-full items-center justify-center space-x-2 bg-gradient-to-r from-teal-500 to-emerald-500 text-white transition-all hover:from-teal-600 hover:to-emerald-600"
|
||||
onClick={() => saveDatamessage()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span>Saving to SurfSense...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadIcon className="h-4 w-4 transition-transform group-hover:scale-110" />
|
||||
<span>Save to SurfSense</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default HomePage
|
||||
export default HomePage;
|
||||
|
|
|
|||
|
|
@ -1,38 +1,37 @@
|
|||
import React from 'react'
|
||||
import icon from "data-base64:~assets/icon.png"
|
||||
import { ReloadIcon } from "@radix-ui/react-icons"
|
||||
import icon from "data-base64:~assets/icon.png";
|
||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-gray-900 to-gray-800">
|
||||
<div className="w-full max-w-md mx-auto space-y-8">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="bg-gray-800 p-3 rounded-full ring-2 ring-gray-700 shadow-lg">
|
||||
<img className="w-12 h-12" src={icon} alt="SurfSense" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-white mt-4">SurfSense</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center mt-8">
|
||||
<ReloadIcon className="h-10 w-10 text-teal-400 animate-spin" />
|
||||
<div className="mt-6 text-lg text-gray-300 flex space-x-1">
|
||||
{Array.from("LOADING").map((letter, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-block animate-pulse text-teal-400"
|
||||
style={{
|
||||
animationDelay: `${i * 0.1}s`,
|
||||
animationDuration: '1.5s'
|
||||
}}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-gray-900 to-gray-800">
|
||||
<div className="w-full max-w-md mx-auto space-y-8">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="bg-gray-800 p-3 rounded-full ring-2 ring-gray-700 shadow-lg">
|
||||
<img className="w-12 h-12" src={icon} alt="SurfSense" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-white mt-4">SurfSense</h1>
|
||||
</div>
|
||||
|
||||
export default Loading
|
||||
<div className="flex flex-col items-center mt-8">
|
||||
<ReloadIcon className="h-10 w-10 text-teal-400 animate-spin" />
|
||||
<div className="mt-6 text-lg text-gray-300 flex space-x-1">
|
||||
{Array.from("LOADING").map((letter, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-block animate-pulse text-teal-400"
|
||||
style={{
|
||||
animationDelay: `${i * 0.1}s`,
|
||||
animationDuration: "1.5s",
|
||||
}}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
|
|
|
|||
|
|
@ -1,56 +1,49 @@
|
|||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
|
|
|||
|
|
@ -1,155 +1,145 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
import type { DialogProps } from "@radix-ui/react-dialog";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import { Dialog, DialogContent } from "~/routes/ui/dialog"
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Dialog, DialogContent } from "~/routes/ui/dialog";
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
interface CommandDialogProps extends DialogProps {}
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
|
||||
));
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
CommandShortcut.displayName = "CommandShortcut";
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,122 +1,104 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
|
|
|
|||
21
surfsense_browser_extension/routes/ui/label.tsx
Normal file
21
surfsense_browser_extension/routes/ui/label.tsx
Normal 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 };
|
||||
|
|
@ -1,31 +1,31 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
|
|
|
|||
|
|
@ -1,129 +1,124 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,35 +1,31 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useToast } from "@/routes/ui/use-toast"
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/routes/ui/toast"
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/routes/ui/toast";
|
||||
import { useToast } from "@/routes/ui/use-toast";
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(({ id, title, description, action, ...props }) => (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
))}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,194 +1,189 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/routes/ui/toast"
|
||||
import type { ToastActionElement, ToastProps } from "@/routes/ui/toast";
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const;
|
||||
|
||||
let count = 0
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||
};
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action;
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
};
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
type Toast = Omit<ToasterToast, "id">;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
export { useToast, toast };
|
||||
|
|
|
|||
|
|
@ -1,76 +1,76 @@
|
|||
const { fontFamily } = require("tailwindcss/defaultTheme")
|
||||
const { fontFamily } = require("tailwindcss/defaultTheme");
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: ["./*.{js,jsx,ts,tsx}","./routes/*.tsx","./routes/**/*.tsx"],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: `var(--radius)`,
|
||||
md: `calc(var(--radius) - 2px)`,
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)", ...fontFamily.sans],
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
darkMode: ["class"],
|
||||
content: ["./*.{js,jsx,ts,tsx}", "./routes/*.tsx", "./routes/**/*.tsx"],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: `var(--radius)`,
|
||||
md: `calc(var(--radius) - 2px)`,
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)", ...fontFamily.sans],
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,96 +3,97 @@
|
|||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 180 100% 37%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
--secondary: 240 5.9% 10%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 240 5.9% 10%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
|
||||
--accent: 169 97% 37%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
:root {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--border: 240 5.9% 24%;
|
||||
--input: 240 5.9% 10%;
|
||||
--ring: 180 100% 37%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
.dark {
|
||||
--background: 224 71% 4%;
|
||||
--foreground: 213 31% 91%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 223 47% 11%;
|
||||
--muted-foreground: 215.4 16.3% 56.9%;
|
||||
--primary: 180 100% 37%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
--accent: 216 34% 17%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--secondary: 240 5.9% 10%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 224 71% 4%;
|
||||
--popover-foreground: 215 20.2% 65.1%;
|
||||
--muted: 240 5.9% 10%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
|
||||
--border: 216 34% 17%;
|
||||
--input: 216 34% 17%;
|
||||
--accent: 169 97% 37%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--card: 224 71% 4%;
|
||||
--card-foreground: 213 31% 91%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 1.2%;
|
||||
--border: 240 5.9% 24%;
|
||||
--input: 240 5.9% 10%;
|
||||
--ring: 180 100% 37%;
|
||||
|
||||
--secondary: 222.2 47.4% 11.2%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
.dark {
|
||||
--background: 224 71% 4%;
|
||||
--foreground: 213 31% 91%;
|
||||
|
||||
--ring: 216 34% 17%;
|
||||
--muted: 223 47% 11%;
|
||||
--muted-foreground: 215.4 16.3% 56.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
--accent: 216 34% 17%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--popover: 224 71% 4%;
|
||||
--popover-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--border: 216 34% 17%;
|
||||
--input: 216 34% 17%;
|
||||
|
||||
--card: 224 71% 4%;
|
||||
--card-foreground: 213 31% 91%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 1.2%;
|
||||
|
||||
--secondary: 222.2 47.4% 11.2%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--ring: 216 34% 17%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 380px;
|
||||
min-height: 580px;
|
||||
min-width: 380px;
|
||||
min-height: 580px;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans",
|
||||
"Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
/* Styling for shadcn/ui components */
|
||||
.command-dialog {
|
||||
@apply dark;
|
||||
}
|
||||
/* Styling for shadcn/ui components */
|
||||
.command-dialog {
|
||||
@apply dark;
|
||||
}
|
||||
}
|
||||
|
||||
/* Popup page dimensions */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
@apply bg-slate-950 text-white;
|
||||
}
|
||||
body {
|
||||
@apply bg-slate-950 text-white;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,12 @@
|
|||
{
|
||||
"extends": "plasmo/templates/tsconfig.base",
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"include": [
|
||||
".plasmo/index.d.ts",
|
||||
"./**/*.ts",
|
||||
"./**/*.tsx"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"~*": [
|
||||
"./*"
|
||||
],
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
}
|
||||
"extends": "plasmo/templates/tsconfig.base.json",
|
||||
"exclude": ["node_modules"],
|
||||
"include": [".plasmo/index.d.ts", "./**/*.ts", "./**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"~*": ["./*"],
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,144 +1,137 @@
|
|||
import { Storage } from "@plasmohq/storage"
|
||||
import type { WebHistory } from "./interfaces"
|
||||
import { Storage } from "@plasmohq/storage";
|
||||
import type { WebHistory } from "./interfaces";
|
||||
|
||||
export const emptyArr: any[] = []
|
||||
export const emptyArr: any[] = [];
|
||||
|
||||
export const initQueues = async (tabId: number) => {
|
||||
const storage = new Storage({ area: "local" })
|
||||
const storage = new Storage({ area: "local" });
|
||||
|
||||
let urlQueueListObj: any = await storage.get("urlQueueList")
|
||||
let timeQueueListObj: any = await storage.get("timeQueueList")
|
||||
const urlQueueListObj: any = await storage.get("urlQueueList");
|
||||
const timeQueueListObj: any = await storage.get("timeQueueList");
|
||||
|
||||
if (!urlQueueListObj && !timeQueueListObj) {
|
||||
await storage.set("urlQueueList", {
|
||||
urlQueueList: [{ tabsessionId: tabId, urlQueue: [] }]
|
||||
})
|
||||
await storage.set("timeQueueList", {
|
||||
timeQueueList: [{ tabsessionId: tabId, timeQueue: [] }]
|
||||
})
|
||||
if (!urlQueueListObj && !timeQueueListObj) {
|
||||
await storage.set("urlQueueList", {
|
||||
urlQueueList: [{ tabsessionId: tabId, urlQueue: [] }],
|
||||
});
|
||||
await storage.set("timeQueueList", {
|
||||
timeQueueList: [{ tabsessionId: tabId, timeQueue: [] }],
|
||||
});
|
||||
|
||||
return
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (urlQueueListObj.urlQueueList && timeQueueListObj.timeQueueList) {
|
||||
const isUrlQueueThere = urlQueueListObj.urlQueueList.find(
|
||||
(data: WebHistory) => data.tabsessionId === tabId
|
||||
)
|
||||
const isTimeQueueThere = timeQueueListObj.timeQueueList.find(
|
||||
(data: WebHistory) => data.tabsessionId === tabId
|
||||
)
|
||||
if (urlQueueListObj.urlQueueList && timeQueueListObj.timeQueueList) {
|
||||
const isUrlQueueThere = urlQueueListObj.urlQueueList.find(
|
||||
(data: WebHistory) => data.tabsessionId === tabId
|
||||
);
|
||||
const isTimeQueueThere = timeQueueListObj.timeQueueList.find(
|
||||
(data: WebHistory) => data.tabsessionId === tabId
|
||||
);
|
||||
|
||||
if (!isUrlQueueThere) {
|
||||
urlQueueListObj.urlQueueList.push({ tabsessionId: tabId, urlQueue: [] })
|
||||
if (!isUrlQueueThere) {
|
||||
urlQueueListObj.urlQueueList.push({ tabsessionId: tabId, urlQueue: [] });
|
||||
|
||||
await storage.set("urlQueueList", {
|
||||
urlQueueList: urlQueueListObj.urlQueueList
|
||||
})
|
||||
}
|
||||
await storage.set("urlQueueList", {
|
||||
urlQueueList: urlQueueListObj.urlQueueList,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isTimeQueueThere) {
|
||||
timeQueueListObj.timeQueueList.push({
|
||||
tabsessionId: tabId,
|
||||
timeQueue: []
|
||||
})
|
||||
if (!isTimeQueueThere) {
|
||||
timeQueueListObj.timeQueueList.push({
|
||||
tabsessionId: tabId,
|
||||
timeQueue: [],
|
||||
});
|
||||
|
||||
await storage.set("timeQueueList", {
|
||||
timeQueueList: timeQueueListObj.timeQueueList
|
||||
})
|
||||
}
|
||||
await storage.set("timeQueueList", {
|
||||
timeQueueList: timeQueueListObj.timeQueueList,
|
||||
});
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
export function getRenderedHtml() {
|
||||
return {
|
||||
url: window.location.href,
|
||||
entryTime: Date.now(),
|
||||
title: document.title,
|
||||
renderedHtml: document.documentElement.outerHTML
|
||||
}
|
||||
return {
|
||||
url: window.location.href,
|
||||
entryTime: Date.now(),
|
||||
title: document.title,
|
||||
renderedHtml: document.documentElement.outerHTML,
|
||||
};
|
||||
}
|
||||
|
||||
export const initWebHistory = async (tabId: number) => {
|
||||
const storage = new Storage({ area: "local" })
|
||||
const result: any = await storage.get("webhistory")
|
||||
const storage = new Storage({ area: "local" });
|
||||
const result: any = await storage.get("webhistory");
|
||||
|
||||
if (result === undefined) {
|
||||
await storage.set("webhistory", { webhistory: emptyArr })
|
||||
return
|
||||
}
|
||||
if (result === undefined) {
|
||||
await storage.set("webhistory", { webhistory: emptyArr });
|
||||
return;
|
||||
}
|
||||
|
||||
const ifIdExists = result.webhistory.find(
|
||||
(data: WebHistory) => data.tabsessionId === tabId
|
||||
)
|
||||
const ifIdExists = result.webhistory.find((data: WebHistory) => data.tabsessionId === tabId);
|
||||
|
||||
if (ifIdExists === undefined) {
|
||||
let webHistory = result.webhistory
|
||||
const initData = {
|
||||
tabsessionId: tabId,
|
||||
tabHistory: emptyArr
|
||||
}
|
||||
if (ifIdExists === undefined) {
|
||||
const webHistory = result.webhistory;
|
||||
const initData = {
|
||||
tabsessionId: tabId,
|
||||
tabHistory: emptyArr,
|
||||
};
|
||||
|
||||
webHistory.push(initData)
|
||||
webHistory.push(initData);
|
||||
|
||||
try {
|
||||
await storage.set("webhistory", { webhistory: webHistory })
|
||||
return
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
await storage.set("webhistory", { webhistory: webHistory });
|
||||
return;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
export function toIsoString(date: Date) {
|
||||
var tzo = -date.getTimezoneOffset(),
|
||||
dif = tzo >= 0 ? "+" : "-",
|
||||
pad = function (num: number) {
|
||||
return (num < 10 ? "0" : "") + num
|
||||
}
|
||||
var tzo = -date.getTimezoneOffset(),
|
||||
dif = tzo >= 0 ? "+" : "-",
|
||||
pad = (num: number) => (num < 10 ? "0" : "") + num;
|
||||
|
||||
return (
|
||||
date.getFullYear() +
|
||||
"-" +
|
||||
pad(date.getMonth() + 1) +
|
||||
"-" +
|
||||
pad(date.getDate()) +
|
||||
"T" +
|
||||
pad(date.getHours()) +
|
||||
":" +
|
||||
pad(date.getMinutes()) +
|
||||
":" +
|
||||
pad(date.getSeconds()) +
|
||||
dif +
|
||||
pad(Math.floor(Math.abs(tzo) / 60)) +
|
||||
":" +
|
||||
pad(Math.abs(tzo) % 60)
|
||||
)
|
||||
return (
|
||||
date.getFullYear() +
|
||||
"-" +
|
||||
pad(date.getMonth() + 1) +
|
||||
"-" +
|
||||
pad(date.getDate()) +
|
||||
"T" +
|
||||
pad(date.getHours()) +
|
||||
":" +
|
||||
pad(date.getMinutes()) +
|
||||
":" +
|
||||
pad(date.getSeconds()) +
|
||||
dif +
|
||||
pad(Math.floor(Math.abs(tzo) / 60)) +
|
||||
":" +
|
||||
pad(Math.abs(tzo) % 60)
|
||||
);
|
||||
}
|
||||
|
||||
export const webhistoryToLangChainDocument = (
|
||||
tabId: number,
|
||||
tabHistory: any[]
|
||||
) => {
|
||||
let toSaveFinally = []
|
||||
for (let j = 0; j < tabHistory.length; j++) {
|
||||
const mtadata = {
|
||||
BrowsingSessionId: `${tabId}`,
|
||||
VisitedWebPageURL: `${tabHistory[j].url}`,
|
||||
VisitedWebPageTitle: `${tabHistory[j].title}`,
|
||||
VisitedWebPageDateWithTimeInISOString: `${toIsoString(new Date(tabHistory[j].entryTime))}`,
|
||||
VisitedWebPageReffererURL: `${tabHistory[j].reffererUrl}`,
|
||||
VisitedWebPageVisitDurationInMilliseconds: tabHistory[j].duration
|
||||
}
|
||||
export const webhistoryToLangChainDocument = (tabId: number, tabHistory: any[]) => {
|
||||
const toSaveFinally = [];
|
||||
for (let j = 0; j < tabHistory.length; j++) {
|
||||
const mtadata = {
|
||||
BrowsingSessionId: `${tabId}`,
|
||||
VisitedWebPageURL: `${tabHistory[j].url}`,
|
||||
VisitedWebPageTitle: `${tabHistory[j].title}`,
|
||||
VisitedWebPageDateWithTimeInISOString: `${toIsoString(new Date(tabHistory[j].entryTime))}`,
|
||||
VisitedWebPageReffererURL: `${tabHistory[j].reffererUrl}`,
|
||||
VisitedWebPageVisitDurationInMilliseconds: tabHistory[j].duration,
|
||||
};
|
||||
|
||||
toSaveFinally.push({
|
||||
metadata: mtadata,
|
||||
pageContent: tabHistory[j].pageContentMarkdown
|
||||
})
|
||||
}
|
||||
toSaveFinally.push({
|
||||
metadata: mtadata,
|
||||
pageContent: tabHistory[j].pageContentMarkdown,
|
||||
});
|
||||
}
|
||||
|
||||
return toSaveFinally
|
||||
}
|
||||
return toSaveFinally;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export interface WebHistory {
|
||||
tabsessionId: number;
|
||||
tabHistory: any[];
|
||||
}
|
||||
tabsessionId: number;
|
||||
tabHistory: any[];
|
||||
}
|
||||
|
|
|
|||
60
surfsense_web/.vscode/launch.json
vendored
60
surfsense_web/.vscode/launch.json
vendored
|
|
@ -1,31 +1,31 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm run debug:server",
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm run debug",
|
||||
"serverReadyAction": {
|
||||
"pattern": "- Local:.+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome"
|
||||
},
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
}
|
||||
]
|
||||
}
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm run debug:server",
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm run debug",
|
||||
"serverReadyAction": {
|
||||
"pattern": "- Local:.+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome"
|
||||
},
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { source } from '@/lib/source';
|
||||
import { createFromSource } from 'fumadocs-core/search/server';
|
||||
|
||||
export const { GET } = createFromSource(source);
|
||||
import { createFromSource } from "fumadocs-core/search/server";
|
||||
import { source } from "@/lib/source";
|
||||
|
||||
export const { GET } = createFromSource(source);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
import { Suspense } from 'react';
|
||||
import TokenHandler from '@/components/TokenHandler';
|
||||
import { Suspense } from "react";
|
||||
import TokenHandler from "@/components/TokenHandler";
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">Authentication Callback</h1>
|
||||
<Suspense fallback={<div className="flex items-center justify-center min-h-[200px]">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
</div>}>
|
||||
<TokenHandler
|
||||
redirectPath="/dashboard"
|
||||
tokenParamName="token"
|
||||
storageKey="surfsense_bearer_token"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">Authentication Callback</h1>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TokenHandler
|
||||
redirectPath="/dashboard"
|
||||
tokenParamName="token"
|
||||
storageKey="surfsense_bearer_token"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,21 +1,25 @@
|
|||
import { Suspense } from 'react';
|
||||
import ChatsPageClient from './chats-client';
|
||||
import { Suspense } from "react";
|
||||
import ChatsPageClient from "./chats-client";
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
search_space_id: string;
|
||||
};
|
||||
params: {
|
||||
search_space_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ChatsPage({ params }: PageProps) {
|
||||
// Get search space ID from the route parameter
|
||||
const { search_space_id: searchSpaceId } = await Promise.resolve(params);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center h-[60vh]">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
</div>}>
|
||||
<ChatsPageClient searchSpaceId={searchSpaceId} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
// Get search space ID from the route parameter
|
||||
const { search_space_id: searchSpaceId } = await Promise.resolve(params);
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-[60vh]">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ChatsPageClient searchSpaceId={searchSpaceId} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,44 +1,40 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"
|
||||
import React from 'react'
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider"
|
||||
import type React from "react";
|
||||
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
|
||||
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
|
||||
export function DashboardClientLayout({
|
||||
children,
|
||||
searchSpaceId,
|
||||
navSecondary,
|
||||
navMain
|
||||
children,
|
||||
searchSpaceId,
|
||||
navSecondary,
|
||||
navMain,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
searchSpaceId: string;
|
||||
navSecondary: any[];
|
||||
navMain: any[];
|
||||
children: React.ReactNode;
|
||||
searchSpaceId: string;
|
||||
navSecondary: any[];
|
||||
navMain: any[];
|
||||
}) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
{/* Use AppSidebarProvider which fetches user, search space, and recent chats */}
|
||||
<AppSidebarProvider
|
||||
searchSpaceId={searchSpaceId}
|
||||
navSecondary={navSecondary}
|
||||
navMain={navMain}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<ThemeTogglerComponent />
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<SidebarProvider>
|
||||
{/* Use AppSidebarProvider which fetches user, search space, and recent chats */}
|
||||
<AppSidebarProvider
|
||||
searchSpaceId={searchSpaceId}
|
||||
navSecondary={navSecondary}
|
||||
navMain={navMain}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<ThemeTogglerComponent />
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
import { motion } from "framer-motion";
|
||||
import { Calendar as CalendarIcon, Edit, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Edit,
|
||||
Plus,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Calendar as CalendarIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { getConnectorIcon } from "@/components/chat";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -40,12 +18,9 @@ import {
|
|||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -53,14 +28,20 @@ import {
|
|||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getConnectorIcon } from "@/components/chat";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { format } from "date-fns";
|
||||
|
||||
// Helper function to format date with time
|
||||
const formatDateTime = (dateString: string | null): string => {
|
||||
|
|
@ -83,14 +64,12 @@ export default function ConnectorsPage() {
|
|||
|
||||
const { connectors, isLoading, error, deleteConnector, indexConnector } =
|
||||
useSearchSourceConnectors();
|
||||
const [connectorToDelete, setConnectorToDelete] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [indexingConnectorId, setIndexingConnectorId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [connectorToDelete, setConnectorToDelete] = useState<number | null>(null);
|
||||
const [indexingConnectorId, setIndexingConnectorId] = useState<number | null>(null);
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false);
|
||||
const [selectedConnectorForIndexing, setSelectedConnectorForIndexing] = useState<number | null>(null);
|
||||
const [selectedConnectorForIndexing, setSelectedConnectorForIndexing] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||
|
||||
|
|
@ -127,21 +106,17 @@ export default function ConnectorsPage() {
|
|||
if (selectedConnectorForIndexing === null) return;
|
||||
|
||||
setDatePickerOpen(false);
|
||||
|
||||
|
||||
try {
|
||||
setIndexingConnectorId(selectedConnectorForIndexing);
|
||||
const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined;
|
||||
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
|
||||
|
||||
|
||||
await indexConnector(selectedConnectorForIndexing, searchSpaceId, startDateStr, endDateStr);
|
||||
toast.success("Connector content indexing started");
|
||||
} catch (error) {
|
||||
console.error("Error indexing connector content:", error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to index connector content",
|
||||
);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to index connector content");
|
||||
} finally {
|
||||
setIndexingConnectorId(null);
|
||||
setSelectedConnectorForIndexing(null);
|
||||
|
|
@ -158,11 +133,7 @@ export default function ConnectorsPage() {
|
|||
toast.success("Connector content indexing started");
|
||||
} catch (error) {
|
||||
console.error("Error indexing connector content:", error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to index connector content",
|
||||
);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to index connector content");
|
||||
} finally {
|
||||
setIndexingConnectorId(null);
|
||||
}
|
||||
|
|
@ -182,11 +153,7 @@ export default function ConnectorsPage() {
|
|||
Manage your connected services and data sources.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors/add`)
|
||||
}
|
||||
>
|
||||
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Connector
|
||||
</Button>
|
||||
|
|
@ -195,9 +162,7 @@ export default function ConnectorsPage() {
|
|||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle>Your Connectors</CardTitle>
|
||||
<CardDescription>
|
||||
View and manage all your connected services.
|
||||
</CardDescription>
|
||||
<CardDescription>View and manage all your connected services.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
|
|
@ -211,14 +176,9 @@ export default function ConnectorsPage() {
|
|||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium mb-2">No connectors found</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
You haven't added any connectors yet. Add one to enhance your
|
||||
search capabilities.
|
||||
You haven't added any connectors yet. Add one to enhance your search capabilities.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors/add`)
|
||||
}
|
||||
>
|
||||
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Your First Connector
|
||||
</Button>
|
||||
|
|
@ -237,12 +197,8 @@ export default function ConnectorsPage() {
|
|||
<TableBody>
|
||||
{connectors.map((connector) => (
|
||||
<TableRow key={connector.id}>
|
||||
<TableCell className="font-medium">
|
||||
{connector.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getConnectorIcon(connector.connector_type)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{connector.name}</TableCell>
|
||||
<TableCell>{getConnectorIcon(connector.connector_type)}</TableCell>
|
||||
<TableCell>
|
||||
{connector.is_indexable
|
||||
? formatDateTime(connector.last_indexed_at)
|
||||
|
|
@ -258,21 +214,15 @@ export default function ConnectorsPage() {
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleOpenDatePicker(connector.id)
|
||||
}
|
||||
disabled={
|
||||
indexingConnectorId === connector.id
|
||||
}
|
||||
onClick={() => handleOpenDatePicker(connector.id)}
|
||||
disabled={indexingConnectorId === connector.id}
|
||||
>
|
||||
{indexingConnectorId === connector.id ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
Index with Date Range
|
||||
</span>
|
||||
<span className="sr-only">Index with Date Range</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
|
@ -286,21 +236,15 @@ export default function ConnectorsPage() {
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleQuickIndexConnector(connector.id)
|
||||
}
|
||||
disabled={
|
||||
indexingConnectorId === connector.id
|
||||
}
|
||||
onClick={() => handleQuickIndexConnector(connector.id)}
|
||||
disabled={indexingConnectorId === connector.id}
|
||||
>
|
||||
{indexingConnectorId === connector.id ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">
|
||||
Quick Index
|
||||
</span>
|
||||
<span className="sr-only">Quick Index</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
|
@ -315,7 +259,7 @@ export default function ConnectorsPage() {
|
|||
size="sm"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`,
|
||||
`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`
|
||||
)
|
||||
}
|
||||
>
|
||||
|
|
@ -328,9 +272,7 @@ export default function ConnectorsPage() {
|
|||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive-foreground hover:bg-destructive/10"
|
||||
onClick={() =>
|
||||
setConnectorToDelete(connector.id)
|
||||
}
|
||||
onClick={() => setConnectorToDelete(connector.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete</span>
|
||||
|
|
@ -338,18 +280,14 @@ export default function ConnectorsPage() {
|
|||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Delete Connector
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogTitle>Delete Connector</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this
|
||||
connector? This action cannot be undone.
|
||||
Are you sure you want to delete this connector? This action cannot
|
||||
be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
onClick={() => setConnectorToDelete(null)}
|
||||
>
|
||||
<AlertDialogCancel onClick={() => setConnectorToDelete(null)}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
|
|
@ -404,9 +342,7 @@ export default function ConnectorsPage() {
|
|||
mode="single"
|
||||
selected={startDate}
|
||||
onSelect={setStartDate}
|
||||
disabled={(date) =>
|
||||
date > new Date() || (endDate ? date > endDate : false)
|
||||
}
|
||||
disabled={(date) => date > new Date() || (endDate ? date > endDate : false)}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
|
|
@ -493,9 +429,7 @@ export default function ConnectorsPage() {
|
|||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleIndexConnector}>
|
||||
Start Indexing
|
||||
</Button>
|
||||
<Button onClick={handleIndexConnector}>Start Indexing</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,280 +1,269 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Check, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Check, Loader2, Github } from "lucide-react";
|
||||
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
// Import Utils, Types, Hook, and Components
|
||||
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
|
||||
import { useConnectorEditPage } from "@/hooks/useConnectorEditPage";
|
||||
import { getConnectorIcon } from "@/components/chat";
|
||||
import { EditConnectorLoadingSkeleton } from "@/components/editConnector/EditConnectorLoadingSkeleton";
|
||||
import { EditConnectorNameForm } from "@/components/editConnector/EditConnectorNameForm";
|
||||
import { EditGitHubConnectorConfig } from "@/components/editConnector/EditGitHubConnectorConfig";
|
||||
import { EditSimpleTokenForm } from "@/components/editConnector/EditSimpleTokenForm";
|
||||
import { getConnectorIcon } from "@/components/chat";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { useConnectorEditPage } from "@/hooks/useConnectorEditPage";
|
||||
// Import Utils, Types, Hook, and Components
|
||||
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
|
||||
|
||||
export default function EditConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
// Ensure connectorId is parsed safely
|
||||
const connectorIdParam = params.connector_id as string;
|
||||
const connectorId = connectorIdParam ? parseInt(connectorIdParam, 10) : NaN;
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
// Ensure connectorId is parsed safely
|
||||
const connectorIdParam = params.connector_id as string;
|
||||
const connectorId = connectorIdParam ? parseInt(connectorIdParam, 10) : NaN;
|
||||
|
||||
// Use the custom hook to manage state and logic
|
||||
const {
|
||||
connectorsLoading,
|
||||
connector,
|
||||
isSaving,
|
||||
editForm,
|
||||
patForm, // Needed for GitHub child component
|
||||
handleSaveChanges,
|
||||
// GitHub specific props for the child component
|
||||
editMode,
|
||||
setEditMode, // Pass down if needed by GitHub component
|
||||
originalPat,
|
||||
currentSelectedRepos,
|
||||
fetchedRepos,
|
||||
setFetchedRepos,
|
||||
newSelectedRepos,
|
||||
setNewSelectedRepos,
|
||||
isFetchingRepos,
|
||||
handleFetchRepositories,
|
||||
handleRepoSelectionChange,
|
||||
} = useConnectorEditPage(connectorId, searchSpaceId);
|
||||
// Use the custom hook to manage state and logic
|
||||
const {
|
||||
connectorsLoading,
|
||||
connector,
|
||||
isSaving,
|
||||
editForm,
|
||||
patForm, // Needed for GitHub child component
|
||||
handleSaveChanges,
|
||||
// GitHub specific props for the child component
|
||||
editMode,
|
||||
setEditMode, // Pass down if needed by GitHub component
|
||||
originalPat,
|
||||
currentSelectedRepos,
|
||||
fetchedRepos,
|
||||
setFetchedRepos,
|
||||
newSelectedRepos,
|
||||
setNewSelectedRepos,
|
||||
isFetchingRepos,
|
||||
handleFetchRepositories,
|
||||
handleRepoSelectionChange,
|
||||
} = useConnectorEditPage(connectorId, searchSpaceId);
|
||||
|
||||
// Redirect if connectorId is not a valid number after parsing
|
||||
useEffect(() => {
|
||||
if (isNaN(connectorId)) {
|
||||
toast.error("Invalid Connector ID.");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
}
|
||||
}, [connectorId, router, searchSpaceId]);
|
||||
// Redirect if connectorId is not a valid number after parsing
|
||||
useEffect(() => {
|
||||
if (Number.isNaN(connectorId)) {
|
||||
toast.error("Invalid Connector ID.");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
}
|
||||
}, [connectorId, router, searchSpaceId]);
|
||||
|
||||
// Loading State
|
||||
if (connectorsLoading || !connector) {
|
||||
// Handle NaN case before showing skeleton
|
||||
if (isNaN(connectorId)) return null;
|
||||
return <EditConnectorLoadingSkeleton />;
|
||||
}
|
||||
// Loading State
|
||||
if (connectorsLoading || !connector) {
|
||||
// Handle NaN case before showing skeleton
|
||||
if (Number.isNaN(connectorId)) return null;
|
||||
return <EditConnectorLoadingSkeleton />;
|
||||
}
|
||||
|
||||
// Main Render using data/handlers from the hook
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Connectors
|
||||
</Button>
|
||||
// Main Render using data/handlers from the hook
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Connectors
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
{getConnectorIcon(connector.connector_type)}
|
||||
Edit {getConnectorTypeDisplay(connector.connector_type)} Connector
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Modify connector name and configuration.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
{getConnectorIcon(connector.connector_type)}
|
||||
Edit {getConnectorTypeDisplay(connector.connector_type)} Connector
|
||||
</CardTitle>
|
||||
<CardDescription>Modify connector name and configuration.</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<Form {...editForm}>
|
||||
{/* Pass hook's handleSaveChanges */}
|
||||
<form
|
||||
onSubmit={editForm.handleSubmit(handleSaveChanges)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Pass form control from hook */}
|
||||
<EditConnectorNameForm control={editForm.control} />
|
||||
<Form {...editForm}>
|
||||
{/* Pass hook's handleSaveChanges */}
|
||||
<form onSubmit={editForm.handleSubmit(handleSaveChanges)} className="space-y-6">
|
||||
<CardContent className="space-y-6">
|
||||
{/* Pass form control from hook */}
|
||||
<EditConnectorNameForm control={editForm.control} />
|
||||
|
||||
<hr />
|
||||
<hr />
|
||||
|
||||
<h3 className="text-lg font-semibold">Configuration</h3>
|
||||
<h3 className="text-lg font-semibold">Configuration</h3>
|
||||
|
||||
{/* == GitHub == */}
|
||||
{connector.connector_type === "GITHUB_CONNECTOR" && (
|
||||
<EditGitHubConnectorConfig
|
||||
// Pass relevant state and handlers from hook
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode} // Pass setter if child manages mode
|
||||
originalPat={originalPat}
|
||||
currentSelectedRepos={currentSelectedRepos}
|
||||
fetchedRepos={fetchedRepos}
|
||||
newSelectedRepos={newSelectedRepos}
|
||||
isFetchingRepos={isFetchingRepos}
|
||||
patForm={patForm}
|
||||
handleFetchRepositories={handleFetchRepositories}
|
||||
handleRepoSelectionChange={handleRepoSelectionChange}
|
||||
setNewSelectedRepos={setNewSelectedRepos}
|
||||
setFetchedRepos={setFetchedRepos}
|
||||
/>
|
||||
)}
|
||||
{/* == GitHub == */}
|
||||
{connector.connector_type === "GITHUB_CONNECTOR" && (
|
||||
<EditGitHubConnectorConfig
|
||||
// Pass relevant state and handlers from hook
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode} // Pass setter if child manages mode
|
||||
originalPat={originalPat}
|
||||
currentSelectedRepos={currentSelectedRepos}
|
||||
fetchedRepos={fetchedRepos}
|
||||
newSelectedRepos={newSelectedRepos}
|
||||
isFetchingRepos={isFetchingRepos}
|
||||
patForm={patForm}
|
||||
handleFetchRepositories={handleFetchRepositories}
|
||||
handleRepoSelectionChange={handleRepoSelectionChange}
|
||||
setNewSelectedRepos={setNewSelectedRepos}
|
||||
setFetchedRepos={setFetchedRepos}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* == Slack == */}
|
||||
{connector.connector_type === "SLACK_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="SLACK_BOT_TOKEN"
|
||||
fieldLabel="Slack Bot Token"
|
||||
fieldDescription="Update the Slack Bot Token if needed."
|
||||
placeholder="Begins with xoxb-..."
|
||||
/>
|
||||
)}
|
||||
{/* == Notion == */}
|
||||
{connector.connector_type === "NOTION_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="NOTION_INTEGRATION_TOKEN"
|
||||
fieldLabel="Notion Integration Token"
|
||||
fieldDescription="Update the Notion Integration Token if needed."
|
||||
placeholder="Begins with secret_..."
|
||||
/>
|
||||
)}
|
||||
{/* == Serper == */}
|
||||
{connector.connector_type === "SERPER_API" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="SERPER_API_KEY"
|
||||
fieldLabel="Serper API Key"
|
||||
fieldDescription="Update the Serper API Key if needed."
|
||||
/>
|
||||
)}
|
||||
{/* == Tavily == */}
|
||||
{connector.connector_type === "TAVILY_API" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="TAVILY_API_KEY"
|
||||
fieldLabel="Tavily API Key"
|
||||
fieldDescription="Update the Tavily API Key if needed."
|
||||
/>
|
||||
)}
|
||||
{/* == Slack == */}
|
||||
{connector.connector_type === "SLACK_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="SLACK_BOT_TOKEN"
|
||||
fieldLabel="Slack Bot Token"
|
||||
fieldDescription="Update the Slack Bot Token if needed."
|
||||
placeholder="Begins with xoxb-..."
|
||||
/>
|
||||
)}
|
||||
{/* == Notion == */}
|
||||
{connector.connector_type === "NOTION_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="NOTION_INTEGRATION_TOKEN"
|
||||
fieldLabel="Notion Integration Token"
|
||||
fieldDescription="Update the Notion Integration Token if needed."
|
||||
placeholder="Begins with secret_..."
|
||||
/>
|
||||
)}
|
||||
{/* == Serper == */}
|
||||
{connector.connector_type === "SERPER_API" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="SERPER_API_KEY"
|
||||
fieldLabel="Serper API Key"
|
||||
fieldDescription="Update the Serper API Key if needed."
|
||||
/>
|
||||
)}
|
||||
{/* == Tavily == */}
|
||||
{connector.connector_type === "TAVILY_API" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="TAVILY_API_KEY"
|
||||
fieldLabel="Tavily API Key"
|
||||
fieldDescription="Update the Tavily API Key if needed."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* == Linear == */}
|
||||
{connector.connector_type === "LINEAR_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="LINEAR_API_KEY"
|
||||
fieldLabel="Linear API Key"
|
||||
fieldDescription="Update your Linear API Key if needed."
|
||||
placeholder="Begins with lin_api_..."
|
||||
/>
|
||||
)}
|
||||
{/* == Linear == */}
|
||||
{connector.connector_type === "LINEAR_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="LINEAR_API_KEY"
|
||||
fieldLabel="Linear API Key"
|
||||
fieldDescription="Update your Linear API Key if needed."
|
||||
placeholder="Begins with lin_api_..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* == Jira == */}
|
||||
{connector.connector_type === "JIRA_CONNECTOR" && (
|
||||
<div className="space-y-4">
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="JIRA_BASE_URL"
|
||||
fieldLabel="Jira Base URL"
|
||||
fieldDescription="Update your Jira instance URL if needed."
|
||||
placeholder="https://yourcompany.atlassian.net"
|
||||
/>
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="JIRA_EMAIL"
|
||||
fieldLabel="Jira Email"
|
||||
fieldDescription="Update your Atlassian account email if needed."
|
||||
placeholder="your.email@company.com"
|
||||
/>
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="JIRA_API_TOKEN"
|
||||
fieldLabel="Jira API Token"
|
||||
fieldDescription="Update your Jira API Token if needed."
|
||||
placeholder="Your Jira API Token"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* == Jira == */}
|
||||
{connector.connector_type === "JIRA_CONNECTOR" && (
|
||||
<div className="space-y-4">
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="JIRA_BASE_URL"
|
||||
fieldLabel="Jira Base URL"
|
||||
fieldDescription="Update your Jira instance URL if needed."
|
||||
placeholder="https://yourcompany.atlassian.net"
|
||||
/>
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="JIRA_EMAIL"
|
||||
fieldLabel="Jira Email"
|
||||
fieldDescription="Update your Atlassian account email if needed."
|
||||
placeholder="your.email@company.com"
|
||||
/>
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="JIRA_API_TOKEN"
|
||||
fieldLabel="Jira API Token"
|
||||
fieldDescription="Update your Jira API Token if needed."
|
||||
placeholder="Your Jira API Token"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* == Confluence == */}
|
||||
{connector.connector_type === "CONFLUENCE_CONNECTOR" && (
|
||||
<div className="space-y-4">
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="CONFLUENCE_BASE_URL"
|
||||
fieldLabel="Confluence Base URL"
|
||||
fieldDescription="Update your Confluence instance URL if needed."
|
||||
placeholder="https://yourcompany.atlassian.net"
|
||||
/>
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="CONFLUENCE_EMAIL"
|
||||
fieldLabel="Confluence Email"
|
||||
fieldDescription="Update your Atlassian account email if needed."
|
||||
placeholder="your.email@company.com"
|
||||
/>
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="CONFLUENCE_API_TOKEN"
|
||||
fieldLabel="Confluence API Token"
|
||||
fieldDescription="Update your Confluence API Token if needed."
|
||||
placeholder="Your Confluence API Token"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* == Confluence == */}
|
||||
{connector.connector_type === "CONFLUENCE_CONNECTOR" && (
|
||||
<div className="space-y-4">
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="CONFLUENCE_BASE_URL"
|
||||
fieldLabel="Confluence Base URL"
|
||||
fieldDescription="Update your Confluence instance URL if needed."
|
||||
placeholder="https://yourcompany.atlassian.net"
|
||||
/>
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="CONFLUENCE_EMAIL"
|
||||
fieldLabel="Confluence Email"
|
||||
fieldDescription="Update your Atlassian account email if needed."
|
||||
placeholder="your.email@company.com"
|
||||
/>
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="CONFLUENCE_API_TOKEN"
|
||||
fieldLabel="Confluence API Token"
|
||||
fieldDescription="Update your Confluence API Token if needed."
|
||||
placeholder="Your Confluence API Token"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* == Linkup == */}
|
||||
{connector.connector_type === "LINKUP_API" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="LINKUP_API_KEY"
|
||||
fieldLabel="Linkup API Key"
|
||||
fieldDescription="Update your Linkup API Key if needed."
|
||||
placeholder="Begins with linkup_..."
|
||||
/>
|
||||
)}
|
||||
{/* == Linkup == */}
|
||||
{connector.connector_type === "LINKUP_API" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="LINKUP_API_KEY"
|
||||
fieldLabel="Linkup API Key"
|
||||
fieldDescription="Update your Linkup API Key if needed."
|
||||
placeholder="Begins with linkup_..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* == Discord == */}
|
||||
{connector.connector_type === "DISCORD_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="DISCORD_BOT_TOKEN"
|
||||
fieldLabel="Discord Bot Token"
|
||||
fieldDescription="Update the Discord Bot Token if needed."
|
||||
placeholder="Bot token..."
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="border-t pt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
{/* == Discord == */}
|
||||
{connector.connector_type === "DISCORD_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="DISCORD_BOT_TOKEN"
|
||||
fieldLabel="Discord Bot Token"
|
||||
fieldDescription="Update the Discord Bot Token if needed."
|
||||
placeholder="Bot token..."
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="border-t pt-6">
|
||||
<Button type="submit" disabled={isSaving} className="w-full sm:w-auto">
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,311 +1,286 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
useSearchSourceConnectors,
|
||||
SearchSourceConnector,
|
||||
} from "@/hooks/useSearchSourceConnectors";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
type SearchSourceConnector,
|
||||
useSearchSourceConnectors,
|
||||
} from "@/hooks/useSearchSourceConnectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const apiConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Helper function to get connector type display name
|
||||
const getConnectorTypeDisplay = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
SERPER_API: "Serper API",
|
||||
TAVILY_API: "Tavily API",
|
||||
SLACK_CONNECTOR: "Slack Connector",
|
||||
NOTION_CONNECTOR: "Notion Connector",
|
||||
GITHUB_CONNECTOR: "GitHub Connector",
|
||||
LINEAR_CONNECTOR: "Linear Connector",
|
||||
JIRA_CONNECTOR: "Jira Connector",
|
||||
DISCORD_CONNECTOR: "Discord Connector",
|
||||
LINKUP_API: "Linkup",
|
||||
// Add other connector types here as needed
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
const typeMap: Record<string, string> = {
|
||||
SERPER_API: "Serper API",
|
||||
TAVILY_API: "Tavily API",
|
||||
SLACK_CONNECTOR: "Slack Connector",
|
||||
NOTION_CONNECTOR: "Notion Connector",
|
||||
GITHUB_CONNECTOR: "GitHub Connector",
|
||||
LINEAR_CONNECTOR: "Linear Connector",
|
||||
JIRA_CONNECTOR: "Jira Connector",
|
||||
DISCORD_CONNECTOR: "Discord Connector",
|
||||
LINKUP_API: "Linkup",
|
||||
// Add other connector types here as needed
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
// Define the type for the form values
|
||||
type ApiConnectorFormValues = z.infer<typeof apiConnectorFormSchema>;
|
||||
|
||||
// Get API key field name based on connector type
|
||||
const getApiKeyFieldName = (connectorType: string): string => {
|
||||
const fieldMap: Record<string, string> = {
|
||||
SERPER_API: "SERPER_API_KEY",
|
||||
TAVILY_API: "TAVILY_API_KEY",
|
||||
SLACK_CONNECTOR: "SLACK_BOT_TOKEN",
|
||||
NOTION_CONNECTOR: "NOTION_INTEGRATION_TOKEN",
|
||||
GITHUB_CONNECTOR: "GITHUB_PAT",
|
||||
DISCORD_CONNECTOR: "DISCORD_BOT_TOKEN",
|
||||
LINKUP_API: "LINKUP_API_KEY",
|
||||
};
|
||||
return fieldMap[connectorType] || "";
|
||||
};
|
||||
|
||||
export default function EditConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const connectorId = parseInt(params.connector_id as string, 10);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const connectorId = parseInt(params.connector_id as string, 10);
|
||||
|
||||
const { connectors, updateConnector } = useSearchSourceConnectors();
|
||||
const [connector, setConnector] = useState<SearchSourceConnector | null>(
|
||||
null,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
// console.log("connector", connector);
|
||||
// Initialize the form
|
||||
const form = useForm<ApiConnectorFormValues>({
|
||||
resolver: zodResolver(apiConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
const { connectors, updateConnector } = useSearchSourceConnectors();
|
||||
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
// console.log("connector", connector);
|
||||
// Initialize the form
|
||||
const form = useForm<ApiConnectorFormValues>({
|
||||
resolver: zodResolver(apiConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Get API key field name based on connector type
|
||||
const getApiKeyFieldName = (connectorType: string): string => {
|
||||
const fieldMap: Record<string, string> = {
|
||||
SERPER_API: "SERPER_API_KEY",
|
||||
TAVILY_API: "TAVILY_API_KEY",
|
||||
SLACK_CONNECTOR: "SLACK_BOT_TOKEN",
|
||||
NOTION_CONNECTOR: "NOTION_INTEGRATION_TOKEN",
|
||||
GITHUB_CONNECTOR: "GITHUB_PAT",
|
||||
DISCORD_CONNECTOR: "DISCORD_BOT_TOKEN",
|
||||
LINKUP_API: "LINKUP_API_KEY",
|
||||
};
|
||||
return fieldMap[connectorType] || "";
|
||||
};
|
||||
// Find connector in the list
|
||||
useEffect(() => {
|
||||
const currentConnector = connectors.find((c) => c.id === connectorId);
|
||||
|
||||
// Find connector in the list
|
||||
useEffect(() => {
|
||||
const currentConnector = connectors.find((c) => c.id === connectorId);
|
||||
if (currentConnector) {
|
||||
setConnector(currentConnector);
|
||||
|
||||
if (currentConnector) {
|
||||
setConnector(currentConnector);
|
||||
// Check if connector type is supported
|
||||
const apiKeyField = getApiKeyFieldName(currentConnector.connector_type);
|
||||
if (apiKeyField) {
|
||||
form.reset({
|
||||
name: currentConnector.name,
|
||||
api_key: currentConnector.config[apiKeyField] || "",
|
||||
});
|
||||
} else {
|
||||
// Redirect if not a supported connector type
|
||||
toast.error("This connector type is not supported for editing");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
}
|
||||
|
||||
// Check if connector type is supported
|
||||
const apiKeyField = getApiKeyFieldName(currentConnector.connector_type);
|
||||
if (apiKeyField) {
|
||||
form.reset({
|
||||
name: currentConnector.name,
|
||||
api_key: currentConnector.config[apiKeyField] || "",
|
||||
});
|
||||
} else {
|
||||
// Redirect if not a supported connector type
|
||||
toast.error("This connector type is not supported for editing");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
}
|
||||
setIsLoading(false);
|
||||
} else if (!isLoading && connectors.length > 0) {
|
||||
// If connectors are loaded but this one isn't found
|
||||
toast.error("Connector not found");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
}
|
||||
}, [connectors, connectorId, form, router, searchSpaceId, isLoading]);
|
||||
|
||||
setIsLoading(false);
|
||||
} else if (!isLoading && connectors.length > 0) {
|
||||
// If connectors are loaded but this one isn't found
|
||||
toast.error("Connector not found");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
}
|
||||
}, [connectors, connectorId, form, router, searchSpaceId, isLoading]);
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: ApiConnectorFormValues) => {
|
||||
if (!connector) return;
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: ApiConnectorFormValues) => {
|
||||
if (!connector) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const apiKeyField = getApiKeyFieldName(connector.connector_type);
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const apiKeyField = getApiKeyFieldName(connector.connector_type);
|
||||
// Only update the API key if a new one was provided
|
||||
const updatedConfig = { ...connector.config };
|
||||
if (values.api_key) {
|
||||
updatedConfig[apiKeyField] = values.api_key;
|
||||
}
|
||||
|
||||
// Only update the API key if a new one was provided
|
||||
const updatedConfig = { ...connector.config };
|
||||
if (values.api_key) {
|
||||
updatedConfig[apiKeyField] = values.api_key;
|
||||
}
|
||||
await updateConnector(connectorId, {
|
||||
name: values.name,
|
||||
connector_type: connector.connector_type,
|
||||
config: updatedConfig,
|
||||
is_indexable: connector.is_indexable,
|
||||
last_indexed_at: connector.last_indexed_at,
|
||||
});
|
||||
|
||||
await updateConnector(connectorId, {
|
||||
name: values.name,
|
||||
connector_type: connector.connector_type,
|
||||
config: updatedConfig,
|
||||
is_indexable: connector.is_indexable,
|
||||
last_indexed_at: connector.last_indexed_at,
|
||||
});
|
||||
toast.success("Connector updated successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error updating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to update connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
toast.success("Connector updated successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error updating connector:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to update connector",
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl flex justify-center items-center min-h-[60vh]">
|
||||
<div className="animate-pulse text-center">
|
||||
<div className="h-8 w-48 bg-muted rounded mx-auto mb-4"></div>
|
||||
<div className="h-4 w-64 bg-muted rounded mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl flex justify-center items-center min-h-[60vh]">
|
||||
<div className="animate-pulse text-center">
|
||||
<div className="h-8 w-48 bg-muted rounded mx-auto mb-4"></div>
|
||||
<div className="h-4 w-64 bg-muted rounded mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
Edit {connector ? getConnectorTypeDisplay(connector.connector_type) : ""} Connector
|
||||
</CardTitle>
|
||||
<CardDescription>Update your connector settings.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Security</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your API key is stored securely. For security reasons, we don't display your
|
||||
existing API key. If you don't update the API key field, your existing key will be
|
||||
preserved.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
Edit{" "}
|
||||
{connector
|
||||
? getConnectorTypeDisplay(connector.connector_type)
|
||||
: ""}{" "}
|
||||
Connector
|
||||
</CardTitle>
|
||||
<CardDescription>Update your connector settings.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Security</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your API key is stored securely. For security reasons, we don't
|
||||
display your existing API key. If you don't update the API key
|
||||
field, your existing key will be preserved.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My API Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>A friendly name to identify this connector.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My API Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Slack Bot Token"
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Notion Integration Token"
|
||||
: connector?.connector_type === "GITHUB_CONNECTOR"
|
||||
? "GitHub Personal Access Token (PAT)"
|
||||
: connector?.connector_type === "LINKUP_API"
|
||||
? "Linkup API Key"
|
||||
: "API Key"}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={
|
||||
connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Enter new Slack Bot Token (optional)"
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Enter new Notion Token (optional)"
|
||||
: connector?.connector_type === "GITHUB_CONNECTOR"
|
||||
? "Enter new GitHub PAT (optional)"
|
||||
: connector?.connector_type === "LINKUP_API"
|
||||
? "Enter new Linkup API Key (optional)"
|
||||
: "Enter new API key (optional)"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Enter a new Slack Bot Token or leave blank to keep your existing token."
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Enter a new Notion Integration Token or leave blank to keep your existing token."
|
||||
: connector?.connector_type === "GITHUB_CONNECTOR"
|
||||
? "Enter a new GitHub PAT or leave blank to keep your existing token."
|
||||
: connector?.connector_type === "LINKUP_API"
|
||||
? "Enter a new Linkup API Key or leave blank to keep your existing key."
|
||||
: "Enter a new API key or leave blank to keep your existing key."}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Slack Bot Token"
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Notion Integration Token"
|
||||
: connector?.connector_type === "GITHUB_CONNECTOR"
|
||||
? "GitHub Personal Access Token (PAT)"
|
||||
: connector?.connector_type === "LINKUP_API"
|
||||
? "Linkup API Key"
|
||||
: "API Key"}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={
|
||||
connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Enter new Slack Bot Token (optional)"
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Enter new Notion Token (optional)"
|
||||
: connector?.connector_type ===
|
||||
"GITHUB_CONNECTOR"
|
||||
? "Enter new GitHub PAT (optional)"
|
||||
: connector?.connector_type === "LINKUP_API"
|
||||
? "Enter new Linkup API Key (optional)"
|
||||
: "Enter new API key (optional)"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Enter a new Slack Bot Token or leave blank to keep your existing token."
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Enter a new Notion Integration Token or leave blank to keep your existing token."
|
||||
: connector?.connector_type === "GITHUB_CONNECTOR"
|
||||
? "Enter a new GitHub PAT or leave blank to keep your existing token."
|
||||
: connector?.connector_type === "LINKUP_API"
|
||||
? "Enter a new Linkup API Key or leave blank to keep your existing key."
|
||||
: "Enter a new API key or leave blank to keep your existing key."}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Update Connector
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Update Connector
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,324 +1,297 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const confluenceConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
base_url: z
|
||||
.string()
|
||||
.url({
|
||||
message:
|
||||
"Please enter a valid Confluence URL (e.g., https://yourcompany.atlassian.net)",
|
||||
})
|
||||
.refine(
|
||||
(url) => {
|
||||
return url.includes("atlassian.net") || url.includes("confluence");
|
||||
},
|
||||
{
|
||||
message: "Please enter a valid Confluence instance URL",
|
||||
},
|
||||
),
|
||||
email: z.string().email({
|
||||
message: "Please enter a valid email address.",
|
||||
}),
|
||||
api_token: z.string().min(10, {
|
||||
message: "Confluence API Token is required and must be valid.",
|
||||
}),
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
base_url: z
|
||||
.string()
|
||||
.url({
|
||||
message: "Please enter a valid Confluence URL (e.g., https://yourcompany.atlassian.net)",
|
||||
})
|
||||
.refine(
|
||||
(url) => {
|
||||
return url.includes("atlassian.net") || url.includes("confluence");
|
||||
},
|
||||
{
|
||||
message: "Please enter a valid Confluence instance URL",
|
||||
}
|
||||
),
|
||||
email: z.string().email({
|
||||
message: "Please enter a valid email address.",
|
||||
}),
|
||||
api_token: z.string().min(10, {
|
||||
message: "Confluence API Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type ConfluenceConnectorFormValues = z.infer<typeof confluenceConnectorFormSchema>;
|
||||
|
||||
export default function ConfluenceConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<ConfluenceConnectorFormValues>({
|
||||
resolver: zodResolver(confluenceConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Confluence Connector",
|
||||
base_url: "",
|
||||
email: "",
|
||||
api_token: "",
|
||||
},
|
||||
});
|
||||
// Initialize the form
|
||||
const form = useForm<ConfluenceConnectorFormValues>({
|
||||
resolver: zodResolver(confluenceConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Confluence Connector",
|
||||
base_url: "",
|
||||
email: "",
|
||||
api_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: ConfluenceConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "CONFLUENCE_CONNECTOR",
|
||||
config: {
|
||||
CONFLUENCE_BASE_URL: values.base_url,
|
||||
CONFLUENCE_EMAIL: values.email,
|
||||
CONFLUENCE_API_TOKEN: values.api_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: ConfluenceConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "CONFLUENCE_CONNECTOR",
|
||||
config: {
|
||||
CONFLUENCE_BASE_URL: values.base_url,
|
||||
CONFLUENCE_EMAIL: values.email,
|
||||
CONFLUENCE_API_TOKEN: values.api_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Confluence connector created successfully!");
|
||||
toast.success("Confluence connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to create connector",
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() =>
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors/add`)
|
||||
}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Connect to Confluence</CardTitle>
|
||||
<CardDescription>
|
||||
Connect your Confluence instance to index pages and comments from your spaces.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
You'll need to create an API token from your{" "}
|
||||
<a
|
||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Atlassian Account Settings
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<TabsContent value="connect">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Connect to Confluence</CardTitle>
|
||||
<CardDescription>
|
||||
Connect your Confluence instance to index pages and comments from your spaces.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
You'll need to create an API token from your{" "}
|
||||
<a
|
||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Atlassian Account Settings
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Confluence Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Confluence Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confluence Instance URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://yourcompany.atlassian.net"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Confluence instance URL. For Atlassian Cloud, this is
|
||||
typically https://yourcompany.atlassian.net
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confluence Instance URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://yourcompany.atlassian.net" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Confluence instance URL. For Atlassian Cloud, this is typically
|
||||
https://yourcompany.atlassian.net
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="your.email@company.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Atlassian account email address.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="your.email@company.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Your Atlassian account email address.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Your Confluence API Token"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Confluence API Token will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Your Confluence API Token"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Confluence API Token will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Confluence
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Confluence
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Confluence Integration Guide</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Confluence connector.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">What gets indexed?</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
|
||||
<li>All pages from accessible spaces</li>
|
||||
<li>Page content and metadata</li>
|
||||
<li>Comments on pages (both footer and inline comments)</li>
|
||||
<li>Page titles and descriptions</li>
|
||||
</ul>
|
||||
</div>
|
||||
<TabsContent value="documentation">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Confluence Integration Guide</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Confluence connector.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">What gets indexed?</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
|
||||
<li>All pages from accessible spaces</li>
|
||||
<li>Page content and metadata</li>
|
||||
<li>Comments on pages (both footer and inline comments)</li>
|
||||
<li>Page titles and descriptions</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Setup Instructions</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
|
||||
<li>Go to your Atlassian Account Settings</li>
|
||||
<li>Navigate to Security → API tokens</li>
|
||||
<li>Create a new API token with appropriate permissions</li>
|
||||
<li>Copy the token and paste it in the form above</li>
|
||||
<li>Ensure your account has read access to the spaces you want to index</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Setup Instructions</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
|
||||
<li>Go to your Atlassian Account Settings</li>
|
||||
<li>Navigate to Security → API tokens</li>
|
||||
<li>Create a new API token with appropriate permissions</li>
|
||||
<li>Copy the token and paste it in the form above</li>
|
||||
<li>Ensure your account has read access to the spaces you want to index</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Permissions Required</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
|
||||
<li>Read access to Confluence spaces</li>
|
||||
<li>View pages and comments</li>
|
||||
<li>Access to space metadata</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Permissions Required</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
|
||||
<li>Read access to Confluence spaces</li>
|
||||
<li>View pages and comments</li>
|
||||
<li>Access to space metadata</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
The connector will only index content that your account has permission to view.
|
||||
Make sure your API token has the necessary permissions for the spaces you want to index.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
The connector will only index content that your account has permission to view.
|
||||
Make sure your API token has the necessary permissions for the spaces you want
|
||||
to index.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,315 +1,345 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const discordConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
bot_token: z.string()
|
||||
.min(50, { message: "Discord Bot Token appears to be too short." })
|
||||
.regex(/^[A-Za-z0-9._-]+$/, { message: "Discord Bot Token contains invalid characters." }),
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
bot_token: z
|
||||
.string()
|
||||
.min(50, { message: "Discord Bot Token appears to be too short." })
|
||||
.regex(/^[A-Za-z0-9._-]+$/, { message: "Discord Bot Token contains invalid characters." }),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type DiscordConnectorFormValues = z.infer<typeof discordConnectorFormSchema>;
|
||||
|
||||
export default function DiscordConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<DiscordConnectorFormValues>({
|
||||
resolver: zodResolver(discordConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Discord Connector",
|
||||
bot_token: "",
|
||||
},
|
||||
});
|
||||
// Initialize the form
|
||||
const form = useForm<DiscordConnectorFormValues>({
|
||||
resolver: zodResolver(discordConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Discord Connector",
|
||||
bot_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: DiscordConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "DISCORD_CONNECTOR",
|
||||
config: {
|
||||
DISCORD_BOT_TOKEN: values.bot_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: DiscordConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "DISCORD_CONNECTOR",
|
||||
config: {
|
||||
DISCORD_BOT_TOKEN: values.bot_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Discord connector created successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
toast.success("Discord connector created successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Discord Server</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Discord to search and retrieve information from your servers and channels. This connector can index your Discord messages for search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Bot Token Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Discord Bot Token to use this connector. You can create a Discord bot and get the token from the{" "}
|
||||
<a
|
||||
href="https://discord.com/developers/applications"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Discord Developer Portal
|
||||
</a>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Discord Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Discord Server</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Discord to search and retrieve information from your servers and
|
||||
channels. This connector can index your Discord messages for search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Bot Token Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Discord Bot Token to use this connector. You can create a Discord
|
||||
bot and get the token from the{" "}
|
||||
<a
|
||||
href="https://discord.com/developers/applications"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Discord Developer Portal
|
||||
</a>
|
||||
.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bot_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Discord Bot Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Bot Token..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Discord Bot Token will be encrypted and stored securely. You can find it in the Bot section of your application in the Discord Developer Portal.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Discord Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Discord
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Discord integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through your Discord servers and channels</li>
|
||||
<li>Access historical messages and shared files</li>
|
||||
<li>Connect your team's knowledge directly to your search space</li>
|
||||
<li>Keep your search results up-to-date with latest communications</li>
|
||||
<li>Index your Discord messages for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Discord Connector Documentation</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Discord connector to index your server data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Discord connector indexes all accessible channels for a given bot in your servers.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>Upcoming: Support for private channels by granting the bot access.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">Authorization</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Bot Setup Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You must create a Discord bot and add it to your server with the correct permissions.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Go to <a href="https://discord.com/developers/applications" target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-4">https://discord.com/developers/applications</a>.</li>
|
||||
<li>Create a new application and add a bot to it.</li>
|
||||
<li>Copy the Bot Token from the Bot section.</li>
|
||||
<li>Invite the bot to your server with the following OAuth2 scopes and permissions:
|
||||
<ul className="list-disc pl-5 mt-1">
|
||||
<li>Scopes: <code>bot</code></li>
|
||||
<li>Bot Permissions: <code>Read Messages/View Channels</code>, <code>Read Message History</code>, <code>Send Messages</code></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Paste the Bot Token above to connect.</li>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Navigate to the Connector Dashboard and select the <strong>Discord</strong> Connector.</li>
|
||||
<li>Place the <strong>Bot Token</strong> under <strong>Step 1 Provide Credentials</strong>.</li>
|
||||
<li>Click <strong>Connect</strong> to establish the connection.</li>
|
||||
</ol>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bot_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Discord Bot Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Bot Token..." {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Discord Bot Token will be encrypted and stored securely. You can
|
||||
find it in the Bot section of your application in the Discord Developer
|
||||
Portal.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Important: Bot Channel Access</AlertTitle>
|
||||
<AlertDescription>
|
||||
After connecting, ensure the bot has access to all channels you want to index. You may need to adjust channel permissions in Discord.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Discord
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Discord integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through your Discord servers and channels</li>
|
||||
<li>Access historical messages and shared files</li>
|
||||
<li>Connect your team's knowledge directly to your search space</li>
|
||||
<li>Keep your search results up-to-date with latest communications</li>
|
||||
<li>Index your Discord messages for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<Alert className="bg-muted mt-4">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>First Indexing</AlertTitle>
|
||||
<AlertDescription>
|
||||
The first indexing pulls all accessible channels and may take longer than future updates. Only channels where the bot has access will be indexed.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="mt-4">
|
||||
<h4 className="font-medium mb-2">Troubleshooting:</h4>
|
||||
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
|
||||
<li>
|
||||
<strong>Missing messages:</strong> If you don't see messages from a channel, check the bot's permissions for that channel.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Bot not responding:</strong> Make sure the bot is online and the token is correct.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Private channels:</strong> The bot must be explicitly granted access to private channels.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
Discord Connector Documentation
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Discord connector to index your server data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Discord connector indexes all accessible channels for a given bot in your
|
||||
servers.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>Upcoming: Support for private channels by granting the bot access.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Authorization
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Bot Setup Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You must create a Discord bot and add it to your server with the correct
|
||||
permissions.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Go to{" "}
|
||||
<a
|
||||
href="https://discord.com/developers/applications"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
https://discord.com/developers/applications
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>Create a new application and add a bot to it.</li>
|
||||
<li>Copy the Bot Token from the Bot section.</li>
|
||||
<li>
|
||||
Invite the bot to your server with the following OAuth2 scopes and
|
||||
permissions:
|
||||
<ul className="list-disc pl-5 mt-1">
|
||||
<li>
|
||||
Scopes: <code>bot</code>
|
||||
</li>
|
||||
<li>
|
||||
Bot Permissions: <code>Read Messages/View Channels</code>,{" "}
|
||||
<code>Read Message History</code>, <code>Send Messages</code>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Paste the Bot Token above to connect.</li>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the{" "}
|
||||
<strong>Discord</strong> Connector.
|
||||
</li>
|
||||
<li>
|
||||
Place the <strong>Bot Token</strong> under{" "}
|
||||
<strong>Step 1 Provide Credentials</strong>.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Important: Bot Channel Access</AlertTitle>
|
||||
<AlertDescription>
|
||||
After connecting, ensure the bot has access to all channels you want to
|
||||
index. You may need to adjust channel permissions in Discord.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert className="bg-muted mt-4">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>First Indexing</AlertTitle>
|
||||
<AlertDescription>
|
||||
The first indexing pulls all accessible channels and may take longer than
|
||||
future updates. Only channels where the bot has access will be indexed.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="mt-4">
|
||||
<h4 className="font-medium mb-2">Troubleshooting:</h4>
|
||||
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
|
||||
<li>
|
||||
<strong>Missing messages:</strong> If you don't see messages from a
|
||||
channel, check the bot's permissions for that channel.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Bot not responding:</strong> Make sure the bot is online and the
|
||||
token is correct.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Private channels:</strong> The bot must be explicitly granted
|
||||
access to private channels.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,59 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Check, CircleAlert, Github, Info, ListChecks, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Check, Info, Loader2, Github, CircleAlert, ListChecks } from "lucide-react";
|
||||
|
||||
// Assuming useSearchSourceConnectors hook exists and works similarly
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
// Assuming useSearchSourceConnectors hook exists and works similarly
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
|
||||
// Define the form schema with Zod for GitHub PAT entry step
|
||||
const githubPatFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
github_pat: z.string()
|
||||
.min(20, { // Apply min length first
|
||||
message: "GitHub Personal Access Token seems too short.",
|
||||
})
|
||||
.refine(pat => pat.startsWith('ghp_') || pat.startsWith('github_pat_'), { // Then refine the pattern
|
||||
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'",
|
||||
}),
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
github_pat: z
|
||||
.string()
|
||||
.min(20, {
|
||||
// Apply min length first
|
||||
message: "GitHub Personal Access Token seems too short.",
|
||||
})
|
||||
.refine((pat) => pat.startsWith("ghp_") || pat.startsWith("github_pat_"), {
|
||||
// Then refine the pattern
|
||||
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
|
|
@ -63,394 +61,468 @@ type GithubPatFormValues = z.infer<typeof githubPatFormSchema>;
|
|||
|
||||
// Type for fetched GitHub repositories
|
||||
interface GithubRepo {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
private: boolean;
|
||||
url: string;
|
||||
description: string | null;
|
||||
last_updated: string | null;
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
private: boolean;
|
||||
url: string;
|
||||
description: string | null;
|
||||
last_updated: string | null;
|
||||
}
|
||||
|
||||
export default function GithubConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [step, setStep] = useState<'enter_pat' | 'select_repos'>('enter_pat');
|
||||
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
|
||||
const [isCreatingConnector, setIsCreatingConnector] = useState(false);
|
||||
const [repositories, setRepositories] = useState<GithubRepo[]>([]);
|
||||
const [selectedRepos, setSelectedRepos] = useState<string[]>([]);
|
||||
const [connectorName, setConnectorName] = useState<string>("GitHub Connector");
|
||||
const [validatedPat, setValidatedPat] = useState<string>(""); // Store the validated PAT
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [step, setStep] = useState<"enter_pat" | "select_repos">("enter_pat");
|
||||
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
|
||||
const [isCreatingConnector, setIsCreatingConnector] = useState(false);
|
||||
const [repositories, setRepositories] = useState<GithubRepo[]>([]);
|
||||
const [selectedRepos, setSelectedRepos] = useState<string[]>([]);
|
||||
const [connectorName, setConnectorName] = useState<string>("GitHub Connector");
|
||||
const [validatedPat, setValidatedPat] = useState<string>(""); // Store the validated PAT
|
||||
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form for PAT entry
|
||||
const form = useForm<GithubPatFormValues>({
|
||||
resolver: zodResolver(githubPatFormSchema),
|
||||
defaultValues: {
|
||||
name: connectorName,
|
||||
github_pat: "",
|
||||
},
|
||||
});
|
||||
// Initialize the form for PAT entry
|
||||
const form = useForm<GithubPatFormValues>({
|
||||
resolver: zodResolver(githubPatFormSchema),
|
||||
defaultValues: {
|
||||
name: connectorName,
|
||||
github_pat: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Function to fetch repositories using the new backend endpoint
|
||||
const fetchRepositories = async (values: GithubPatFormValues) => {
|
||||
setIsFetchingRepos(true);
|
||||
setConnectorName(values.name); // Store the name
|
||||
setValidatedPat(values.github_pat); // Store the PAT temporarily
|
||||
try {
|
||||
const token = localStorage.getItem('surfsense_bearer_token');
|
||||
if (!token) {
|
||||
throw new Error('No authentication token found');
|
||||
}
|
||||
// Function to fetch repositories using the new backend endpoint
|
||||
const fetchRepositories = async (values: GithubPatFormValues) => {
|
||||
setIsFetchingRepos(true);
|
||||
setConnectorName(values.name); // Store the name
|
||||
setValidatedPat(values.github_pat); // Store the PAT temporarily
|
||||
try {
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
if (!token) {
|
||||
throw new Error("No authentication token found");
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories/`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ github_pat: values.github_pat })
|
||||
}
|
||||
);
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories/`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ github_pat: values.github_pat }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || `Failed to fetch repositories: ${response.statusText}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || `Failed to fetch repositories: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: GithubRepo[] = await response.json();
|
||||
setRepositories(data);
|
||||
setStep('select_repos'); // Move to the next step
|
||||
toast.success(`Found ${data.length} repositories.`);
|
||||
} catch (error) {
|
||||
console.error("Error fetching GitHub repositories:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to fetch repositories. Please check the PAT and try again.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsFetchingRepos(false);
|
||||
}
|
||||
};
|
||||
const data: GithubRepo[] = await response.json();
|
||||
setRepositories(data);
|
||||
setStep("select_repos"); // Move to the next step
|
||||
toast.success(`Found ${data.length} repositories.`);
|
||||
} catch (error) {
|
||||
console.error("Error fetching GitHub repositories:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch repositories. Please check the PAT and try again.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsFetchingRepos(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle final connector creation
|
||||
const handleCreateConnector = async () => {
|
||||
if (selectedRepos.length === 0) {
|
||||
toast.warning("Please select at least one repository to index.");
|
||||
return;
|
||||
}
|
||||
// Handle final connector creation
|
||||
const handleCreateConnector = async () => {
|
||||
if (selectedRepos.length === 0) {
|
||||
toast.warning("Please select at least one repository to index.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingConnector(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: connectorName, // Use the stored name
|
||||
connector_type: "GITHUB_CONNECTOR",
|
||||
config: {
|
||||
GITHUB_PAT: validatedPat, // Use the stored validated PAT
|
||||
repo_full_names: selectedRepos, // Add the selected repo names
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
setIsCreatingConnector(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: connectorName, // Use the stored name
|
||||
connector_type: "GITHUB_CONNECTOR",
|
||||
config: {
|
||||
GITHUB_PAT: validatedPat, // Use the stored validated PAT
|
||||
repo_full_names: selectedRepos, // Add the selected repo names
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("GitHub connector created successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating GitHub connector:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to create GitHub connector.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsCreatingConnector(false);
|
||||
}
|
||||
};
|
||||
toast.success("GitHub connector created successfully!");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating GitHub connector:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to create GitHub connector.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsCreatingConnector(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle checkbox changes
|
||||
const handleRepoSelection = (repoFullName: string, checked: boolean) => {
|
||||
setSelectedRepos(prev =>
|
||||
checked
|
||||
? [...prev, repoFullName]
|
||||
: prev.filter(name => name !== repoFullName)
|
||||
);
|
||||
};
|
||||
// Handle checkbox changes
|
||||
const handleRepoSelection = (repoFullName: string, checked: boolean) => {
|
||||
setSelectedRepos((prev) =>
|
||||
checked ? [...prev, repoFullName] : prev.filter((name) => name !== repoFullName)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => {
|
||||
if (step === 'select_repos') {
|
||||
// Go back to PAT entry, clear sensitive/fetched data
|
||||
setStep('enter_pat');
|
||||
setRepositories([]);
|
||||
setSelectedRepos([]);
|
||||
setValidatedPat("");
|
||||
// Reset form PAT field, keep name
|
||||
form.reset({ name: connectorName, github_pat: "" });
|
||||
} else {
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors/add`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{step === 'select_repos' ? "Back to PAT Entry" : "Back to Add Connectors"}
|
||||
</Button>
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => {
|
||||
if (step === "select_repos") {
|
||||
// Go back to PAT entry, clear sensitive/fetched data
|
||||
setStep("enter_pat");
|
||||
setRepositories([]);
|
||||
setSelectedRepos([]);
|
||||
setValidatedPat("");
|
||||
// Reset form PAT field, keep name
|
||||
form.reset({ name: connectorName, github_pat: "" });
|
||||
} else {
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors/add`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{step === "select_repos" ? "Back to PAT Entry" : "Back to Add Connectors"}
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect GitHub</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Setup Guide</TabsTrigger>
|
||||
</TabsList>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect GitHub</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Setup Guide</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
{step === 'enter_pat' ? <Github className="h-6 w-6" /> : <ListChecks className="h-6 w-6" />}
|
||||
{step === 'enter_pat' ? "Connect GitHub Account" : "Select Repositories to Index"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{step === 'enter_pat'
|
||||
? "Provide a name and GitHub Personal Access Token (PAT) to fetch accessible repositories."
|
||||
: `Select which repositories you want SurfSense to index for search. Found ${repositories.length} repositories accessible via your PAT.`
|
||||
}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
{step === "enter_pat" ? (
|
||||
<Github className="h-6 w-6" />
|
||||
) : (
|
||||
<ListChecks className="h-6 w-6" />
|
||||
)}
|
||||
{step === "enter_pat" ? "Connect GitHub Account" : "Select Repositories to Index"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{step === "enter_pat"
|
||||
? "Provide a name and GitHub Personal Access Token (PAT) to fetch accessible repositories."
|
||||
: `Select which repositories you want SurfSense to index for search. Found ${repositories.length} repositories accessible via your PAT.`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<Form {...form}>
|
||||
{step === 'enter_pat' && (
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>GitHub Personal Access Token (PAT) Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to fetch repositories. You can create one from your{' '}
|
||||
<a
|
||||
href="https://github.com/settings/personal-access-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
GitHub Developer Settings
|
||||
</a>. The PAT will be used to fetch repositories and then stored securely to enable indexing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Form {...form}>
|
||||
{step === "enter_pat" && (
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>GitHub Personal Access Token (PAT) Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to fetch
|
||||
repositories. You can create one from your{" "}
|
||||
<a
|
||||
href="https://github.com/settings/personal-access-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
GitHub Developer Settings
|
||||
</a>
|
||||
. The PAT will be used to fetch repositories and then stored securely to
|
||||
enable indexing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<form onSubmit={form.handleSubmit(fetchRepositories)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My GitHub Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this GitHub connection.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<form onSubmit={form.handleSubmit(fetchRepositories)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My GitHub Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this GitHub connection.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="github_pat"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>GitHub Personal Access Token (PAT)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="ghp_... or github_pat_..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter your GitHub PAT here to fetch your repositories. It will be stored encrypted later.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="github_pat"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>GitHub Personal Access Token (PAT)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="ghp_... or github_pat_..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter your GitHub PAT here to fetch your repositories. It will be
|
||||
stored encrypted later.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isFetchingRepos}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isFetchingRepos ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Fetching Repositories...
|
||||
</>
|
||||
) : (
|
||||
"Fetch Repositories"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isFetchingRepos}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isFetchingRepos ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Fetching Repositories...
|
||||
</>
|
||||
) : (
|
||||
"Fetch Repositories"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{step === 'select_repos' && (
|
||||
<CardContent>
|
||||
{repositories.length === 0 ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert className="h-4 w-4" />
|
||||
<AlertTitle>No Repositories Found</AlertTitle>
|
||||
<AlertDescription>
|
||||
No repositories were found or accessible with the provided PAT. Please check the token and its permissions, then go back and try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<FormLabel>Repositories ({selectedRepos.length} selected)</FormLabel>
|
||||
<div className="h-64 w-full rounded-md border p-4 overflow-y-auto">
|
||||
{repositories.map((repo) => (
|
||||
<div key={repo.id} className="flex items-center space-x-2 mb-2 py-1">
|
||||
<Checkbox
|
||||
id={`repo-${repo.id}`}
|
||||
checked={selectedRepos.includes(repo.full_name)}
|
||||
onCheckedChange={(checked) => handleRepoSelection(repo.full_name, !!checked)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`repo-${repo.id}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{repo.full_name} {repo.private && "(Private)"}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FormDescription>
|
||||
Select the repositories you wish to index. Only checked repositories will be processed.
|
||||
</FormDescription>
|
||||
{step === "select_repos" && (
|
||||
<CardContent>
|
||||
{repositories.length === 0 ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert className="h-4 w-4" />
|
||||
<AlertTitle>No Repositories Found</AlertTitle>
|
||||
<AlertDescription>
|
||||
No repositories were found or accessible with the provided PAT. Please
|
||||
check the token and its permissions, then go back and try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<FormLabel>Repositories ({selectedRepos.length} selected)</FormLabel>
|
||||
<div className="h-64 w-full rounded-md border p-4 overflow-y-auto">
|
||||
{repositories.map((repo) => (
|
||||
<div key={repo.id} className="flex items-center space-x-2 mb-2 py-1">
|
||||
<Checkbox
|
||||
id={`repo-${repo.id}`}
|
||||
checked={selectedRepos.includes(repo.full_name)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRepoSelection(repo.full_name, !!checked)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`repo-${repo.id}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{repo.full_name} {repo.private && "(Private)"}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FormDescription>
|
||||
Select the repositories you wish to index. Only checked repositories will
|
||||
be processed.
|
||||
</FormDescription>
|
||||
|
||||
<div className="flex justify-between items-center pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStep('enter_pat');
|
||||
setRepositories([]);
|
||||
setSelectedRepos([]);
|
||||
setValidatedPat("");
|
||||
form.reset({ name: connectorName, github_pat: "" });
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateConnector}
|
||||
disabled={isCreatingConnector || selectedRepos.length === 0}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isCreatingConnector ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating Connector...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Create Connector
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Form>
|
||||
<div className="flex justify-between items-center pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStep("enter_pat");
|
||||
setRepositories([]);
|
||||
setSelectedRepos([]);
|
||||
setValidatedPat("");
|
||||
form.reset({ name: connectorName, github_pat: "" });
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateConnector}
|
||||
disabled={isCreatingConnector || selectedRepos.length === 0}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isCreatingConnector ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating Connector...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Create Connector
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with GitHub integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through code and documentation in your selected repositories</li>
|
||||
<li>Access READMEs, Markdown files, and common code files</li>
|
||||
<li>Connect your project knowledge directly to your search space</li>
|
||||
<li>Index your selected repositories for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with GitHub integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through code and documentation in your selected repositories</li>
|
||||
<li>Access READMEs, Markdown files, and common code files</li>
|
||||
<li>Connect your project knowledge directly to your search space</li>
|
||||
<li>Index your selected repositories for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">GitHub Connector Setup Guide</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to generate a Personal Access Token (PAT) and connect your GitHub account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The GitHub connector uses a Personal Access Token (PAT) to authenticate with the GitHub API. First, it fetches a list of repositories accessible to the token. You then select which repositories you want to index. The connector indexes relevant files (code, markdown, text) from only the selected repositories.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>The connector indexes files based on common code and documentation extensions.</li>
|
||||
<li>Large files (over 1MB) are skipped during indexing.</li>
|
||||
<li>Only selected repositories are indexed.</li>
|
||||
<li>Indexing runs periodically (check connector settings for frequency) to keep content up-to-date.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">GitHub Connector Setup Guide</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to generate a Personal Access Token (PAT) and connect your GitHub
|
||||
account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The GitHub connector uses a Personal Access Token (PAT) to authenticate with the
|
||||
GitHub API. First, it fetches a list of repositories accessible to the token.
|
||||
You then select which repositories you want to index. The connector indexes
|
||||
relevant files (code, markdown, text) from only the selected repositories.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>
|
||||
The connector indexes files based on common code and documentation extensions.
|
||||
</li>
|
||||
<li>Large files (over 1MB) are skipped during indexing.</li>
|
||||
<li>Only selected repositories are indexed.</li>
|
||||
<li>
|
||||
Indexing runs periodically (check connector settings for frequency) to keep
|
||||
content up-to-date.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="create_pat">
|
||||
<AccordionTrigger className="text-lg font-medium">Step 1: Generate GitHub PAT</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Generating a Token:</h4>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Go to your GitHub <a href="https://github.com/settings/tokens" target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-4">Developer settings</a>.</li>
|
||||
<li>Click on <strong>Personal access tokens</strong>, then choose <strong>Tokens (classic)</strong> or <strong>Fine-grained tokens</strong> (recommended if available and suitable).</li>
|
||||
<li>Click <strong>Generate new token</strong> (and choose the appropriate type).</li>
|
||||
<li>Give your token a descriptive name (e.g., "SurfSense Connector").</li>
|
||||
<li>Set an expiration date for the token (recommended for security).</li>
|
||||
<li>Under <strong>Select scopes</strong> (for classic tokens) or <strong>Repository access</strong> (for fine-grained), grant the necessary permissions. At minimum, the <strong>`repo`</strong> scope (or equivalent read access to repositories for fine-grained tokens) is required to read repository content.</li>
|
||||
<li>Click <strong>Generate token</strong>.</li>
|
||||
<li><strong>Important:</strong> Copy your new PAT immediately. You won't be able to see it again after leaving the page.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="create_pat">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Step 1: Generate GitHub PAT
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Generating a Token:</h4>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Go to your GitHub{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Developer settings
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
Click on <strong>Personal access tokens</strong>, then choose{" "}
|
||||
<strong>Tokens (classic)</strong> or{" "}
|
||||
<strong>Fine-grained tokens</strong> (recommended if available and
|
||||
suitable).
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Generate new token</strong> (and choose the appropriate
|
||||
type).
|
||||
</li>
|
||||
<li>
|
||||
Give your token a descriptive name (e.g., "SurfSense Connector").
|
||||
</li>
|
||||
<li>
|
||||
Set an expiration date for the token (recommended for security).
|
||||
</li>
|
||||
<li>
|
||||
Under <strong>Select scopes</strong> (for classic tokens) or{" "}
|
||||
<strong>Repository access</strong> (for fine-grained), grant the
|
||||
necessary permissions. At minimum, the <strong>`repo`</strong> scope
|
||||
(or equivalent read access to repositories for fine-grained tokens) is
|
||||
required to read repository content.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Generate token</strong>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Important:</strong> Copy your new PAT immediately. You won't
|
||||
be able to see it again after leaving the page.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="connect_app">
|
||||
<AccordionTrigger className="text-lg font-medium">Step 2: Connect in SurfSense</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Navigate to the "Connect GitHub" tab.</li>
|
||||
<li>Enter a name for your connector.</li>
|
||||
<li>Paste the copied GitHub PAT into the "GitHub Personal Access Token (PAT)" field.</li>
|
||||
<li>Click <strong>Fetch Repositories</strong>.</li>
|
||||
<li>If the PAT is valid, you'll see a list of your accessible repositories.</li>
|
||||
<li>Select the repositories you want SurfSense to index using the checkboxes.</li>
|
||||
<li>Click the <strong>Create Connector</strong> button.</li>
|
||||
<li>If the connection is successful, you will be redirected and can start indexing from the Connectors page.</li>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<AccordionItem value="connect_app">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Step 2: Connect in SurfSense
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Navigate to the "Connect GitHub" tab.</li>
|
||||
<li>Enter a name for your connector.</li>
|
||||
<li>
|
||||
Paste the copied GitHub PAT into the "GitHub Personal Access Token (PAT)"
|
||||
field.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Fetch Repositories</strong>.
|
||||
</li>
|
||||
<li>
|
||||
If the PAT is valid, you'll see a list of your accessible repositories.
|
||||
</li>
|
||||
<li>
|
||||
Select the repositories you want SurfSense to index using the checkboxes.
|
||||
</li>
|
||||
<li>
|
||||
Click the <strong>Create Connector</strong> button.
|
||||
</li>
|
||||
<li>
|
||||
If the connection is successful, you will be redirected and can start
|
||||
indexing from the Connectors page.
|
||||
</li>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,472 +1,401 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const jiraConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
base_url: z
|
||||
.string()
|
||||
.url({
|
||||
message:
|
||||
"Please enter a valid Jira URL (e.g., https://yourcompany.atlassian.net)",
|
||||
})
|
||||
.refine(
|
||||
(url) => {
|
||||
return url.includes("atlassian.net") || url.includes("jira");
|
||||
},
|
||||
{
|
||||
message: "Please enter a valid Jira instance URL",
|
||||
},
|
||||
),
|
||||
email: z.string().email({
|
||||
message: "Please enter a valid email address.",
|
||||
}),
|
||||
api_token: z.string().min(10, {
|
||||
message: "Jira API Token is required and must be valid.",
|
||||
}),
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
base_url: z
|
||||
.string()
|
||||
.url({
|
||||
message: "Please enter a valid Jira URL (e.g., https://yourcompany.atlassian.net)",
|
||||
})
|
||||
.refine(
|
||||
(url) => {
|
||||
return url.includes("atlassian.net") || url.includes("jira");
|
||||
},
|
||||
{
|
||||
message: "Please enter a valid Jira instance URL",
|
||||
}
|
||||
),
|
||||
email: z.string().email({
|
||||
message: "Please enter a valid email address.",
|
||||
}),
|
||||
api_token: z.string().min(10, {
|
||||
message: "Jira API Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type JiraConnectorFormValues = z.infer<typeof jiraConnectorFormSchema>;
|
||||
|
||||
export default function JiraConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<JiraConnectorFormValues>({
|
||||
resolver: zodResolver(jiraConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Jira Connector",
|
||||
base_url: "",
|
||||
email: "",
|
||||
api_token: "",
|
||||
},
|
||||
});
|
||||
// Initialize the form
|
||||
const form = useForm<JiraConnectorFormValues>({
|
||||
resolver: zodResolver(jiraConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Jira Connector",
|
||||
base_url: "",
|
||||
email: "",
|
||||
api_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: JiraConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "JIRA_CONNECTOR",
|
||||
config: {
|
||||
JIRA_BASE_URL: values.base_url,
|
||||
JIRA_EMAIL: values.email,
|
||||
JIRA_API_TOKEN: values.api_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: JiraConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "JIRA_CONNECTOR",
|
||||
config: {
|
||||
JIRA_BASE_URL: values.base_url,
|
||||
JIRA_EMAIL: values.email,
|
||||
JIRA_API_TOKEN: values.api_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Jira connector created successfully!");
|
||||
toast.success("Jira connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to create connector",
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() =>
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors/add`)
|
||||
}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
Connect Jira Instance
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Jira to search and retrieve information from
|
||||
your issues, tickets, and comments. This connector can index
|
||||
your Jira content for search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Jira Personal Access Token Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Jira Personal Access Token to use this
|
||||
connector. You can create one from{" "}
|
||||
<a
|
||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Atlassian Account Settings
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Jira Instance</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Jira to search and retrieve information from your issues, tickets,
|
||||
and comments. This connector can index your Jira content for search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Jira Personal Access Token Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Jira Personal Access Token to use this connector. You can create
|
||||
one from{" "}
|
||||
<a
|
||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Atlassian Account Settings
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Jira Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Jira Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Jira Instance URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://yourcompany.atlassian.net"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Jira instance URL. For Atlassian Cloud, this is
|
||||
typically https://yourcompany.atlassian.net
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Jira Instance URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://yourcompany.atlassian.net" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Jira instance URL. For Atlassian Cloud, this is typically
|
||||
https://yourcompany.atlassian.net
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="your.email@company.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Atlassian account email address.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="your.email@company.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Your Atlassian account email address.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Your Jira API Token"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Jira API Token will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Your Jira API Token" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Jira API Token will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Jira
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">
|
||||
What you get with Jira integration:
|
||||
</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through all your Jira issues and tickets</li>
|
||||
<li>
|
||||
Access issue descriptions, comments, and full discussion
|
||||
threads
|
||||
</li>
|
||||
<li>
|
||||
Connect your team's project management directly to your
|
||||
search space
|
||||
</li>
|
||||
<li>
|
||||
Keep your search results up-to-date with latest Jira content
|
||||
</li>
|
||||
<li>
|
||||
Index your Jira issues for enhanced search capabilities
|
||||
</li>
|
||||
<li>
|
||||
Search by issue keys, status, priority, and assignee
|
||||
information
|
||||
</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Jira
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Jira integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through all your Jira issues and tickets</li>
|
||||
<li>Access issue descriptions, comments, and full discussion threads</li>
|
||||
<li>Connect your team's project management directly to your search space</li>
|
||||
<li>Keep your search results up-to-date with latest Jira content</li>
|
||||
<li>Index your Jira issues for enhanced search capabilities</li>
|
||||
<li>Search by issue keys, status, priority, and assignee information</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
Jira Connector Documentation
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Jira connector to index your
|
||||
project management data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Jira connector uses the Jira REST API with Basic Authentication
|
||||
to fetch all issues and comments that your account has
|
||||
access to within your Jira instance.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>
|
||||
For follow up indexing runs, the connector retrieves
|
||||
issues and comments that have been updated since the last
|
||||
indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run periodically, so updates
|
||||
should appear in your search results within minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Jira Connector Documentation</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Jira connector to index your project management
|
||||
data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Jira connector uses the Jira REST API with Basic Authentication to fetch all
|
||||
issues and comments that your account has access to within your Jira instance.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>
|
||||
For follow up indexing runs, the connector retrieves issues and comments that
|
||||
have been updated since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run periodically, so updates should appear in your
|
||||
search results within minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Authorization
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Read-Only Access is Sufficient</AlertTitle>
|
||||
<AlertDescription>
|
||||
You only need read access for this connector to work.
|
||||
The API Token will only be used to read your Jira data.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Authorization
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Read-Only Access is Sufficient</AlertTitle>
|
||||
<AlertDescription>
|
||||
You only need read access for this connector to work. The API Token will
|
||||
only be used to read your Jira data.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">
|
||||
Step 1: Create an API Token
|
||||
</h4>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Log in to your Atlassian account</li>
|
||||
<li>
|
||||
Navigate to{" "}
|
||||
<a
|
||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
https://id.atlassian.com/manage-profile/security/api-tokens
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Create API token</strong>
|
||||
</li>
|
||||
<li>
|
||||
Enter a label for your token (like "SurfSense
|
||||
Connector")
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Create</strong>
|
||||
</li>
|
||||
<li>
|
||||
Copy the generated token as it will only be shown
|
||||
once
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Step 1: Create an API Token</h4>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Log in to your Atlassian account</li>
|
||||
<li>
|
||||
Navigate to{" "}
|
||||
<a
|
||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
https://id.atlassian.com/manage-profile/security/api-tokens
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Create API token</strong>
|
||||
</li>
|
||||
<li>Enter a label for your token (like "SurfSense Connector")</li>
|
||||
<li>
|
||||
Click <strong>Create</strong>
|
||||
</li>
|
||||
<li>Copy the generated token as it will only be shown once</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">
|
||||
Step 2: Grant necessary access
|
||||
</h4>
|
||||
<p className="text-muted-foreground mb-3">
|
||||
The API Token will have access to all projects and
|
||||
issues that your user account can see. Make sure your
|
||||
account has appropriate permissions for the projects
|
||||
you want to index.
|
||||
</p>
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Data Privacy</AlertTitle>
|
||||
<AlertDescription>
|
||||
Only issues, comments, and basic metadata will be
|
||||
indexed. Jira attachments and linked files are not
|
||||
indexed by this connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Step 2: Grant necessary access</h4>
|
||||
<p className="text-muted-foreground mb-3">
|
||||
The API Token will have access to all projects and issues that your user
|
||||
account can see. Make sure your account has appropriate permissions for
|
||||
the projects you want to index.
|
||||
</p>
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Data Privacy</AlertTitle>
|
||||
<AlertDescription>
|
||||
Only issues, comments, and basic metadata will be indexed. Jira
|
||||
attachments and linked files are not indexed by this connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Indexing
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the{" "}
|
||||
<strong>Jira</strong> Connector.
|
||||
</li>
|
||||
<li>
|
||||
Enter your <strong>Jira Instance URL</strong> (e.g.,
|
||||
https://yourcompany.atlassian.net)
|
||||
</li>
|
||||
<li>
|
||||
Place your <strong>Personal Access Token</strong> in
|
||||
the form field.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the
|
||||
connection.
|
||||
</li>
|
||||
<li>
|
||||
Once connected, your Jira issues will be indexed
|
||||
automatically.
|
||||
</li>
|
||||
</ol>
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>Jira</strong>{" "}
|
||||
Connector.
|
||||
</li>
|
||||
<li>
|
||||
Enter your <strong>Jira Instance URL</strong> (e.g.,
|
||||
https://yourcompany.atlassian.net)
|
||||
</li>
|
||||
<li>
|
||||
Place your <strong>Personal Access Token</strong> in the form field.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your Jira issues will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>What Gets Indexed</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="mb-2">
|
||||
The Jira connector indexes the following data:
|
||||
</p>
|
||||
<ul className="list-disc pl-5">
|
||||
<li>Issue keys and summaries (e.g., PROJ-123)</li>
|
||||
<li>Issue descriptions</li>
|
||||
<li>Issue comments and discussion threads</li>
|
||||
<li>
|
||||
Issue status, priority, and type information
|
||||
</li>
|
||||
<li>Assignee and reporter information</li>
|
||||
<li>Project information</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>What Gets Indexed</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="mb-2">The Jira connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5">
|
||||
<li>Issue keys and summaries (e.g., PROJ-123)</li>
|
||||
<li>Issue descriptions</li>
|
||||
<li>Issue comments and discussion threads</li>
|
||||
<li>Issue status, priority, and type information</li>
|
||||
<li>Assignee and reporter information</li>
|
||||
<li>Project information</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,321 +1,353 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const linearConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "Linear API Key is required and must be valid.",
|
||||
}).regex(/^lin_api_/, {
|
||||
message: "Linear API Key should start with 'lin_api_'",
|
||||
}),
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z
|
||||
.string()
|
||||
.min(10, {
|
||||
message: "Linear API Key is required and must be valid.",
|
||||
})
|
||||
.regex(/^lin_api_/, {
|
||||
message: "Linear API Key should start with 'lin_api_'",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type LinearConnectorFormValues = z.infer<typeof linearConnectorFormSchema>;
|
||||
|
||||
export default function LinearConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<LinearConnectorFormValues>({
|
||||
resolver: zodResolver(linearConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Linear Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
// Initialize the form
|
||||
const form = useForm<LinearConnectorFormValues>({
|
||||
resolver: zodResolver(linearConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Linear Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: LinearConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "LINEAR_CONNECTOR",
|
||||
config: {
|
||||
LINEAR_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: LinearConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "LINEAR_CONNECTOR",
|
||||
config: {
|
||||
LINEAR_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Linear connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
toast.success("Linear connector created successfully!");
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Linear Workspace</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Linear to search and retrieve information from your issues and comments. This connector can index your Linear content for search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Linear API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Linear API Key to use this connector. You can create a Linear API key from{" "}
|
||||
<a
|
||||
href="https://linear.app/settings/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Linear API Settings
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Linear Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Linear API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="lin_api_..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Linear API Key will be encrypted and stored securely. It typically starts with "lin_api_".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Linear Workspace</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Linear to search and retrieve information from your issues and
|
||||
comments. This connector can index your Linear content for search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Linear API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Linear API Key to use this connector. You can create a Linear API
|
||||
key from{" "}
|
||||
<a
|
||||
href="https://linear.app/settings/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Linear API Settings
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Linear
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Linear integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through all your Linear issues and comments</li>
|
||||
<li>Access issue titles, descriptions, and full discussion threads</li>
|
||||
<li>Connect your team's project management directly to your search space</li>
|
||||
<li>Keep your search results up-to-date with latest Linear content</li>
|
||||
<li>Index your Linear issues for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Linear Connector Documentation</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Linear connector to index your project management data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Linear connector uses the Linear GraphQL API to fetch all issues and comments that the API key has access to within a workspace.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>For follow up indexing runs, the connector retrieves issues and comments that have been updated since the last indexing attempt.</li>
|
||||
<li>Indexing is configured to run periodically, so updates should appear in your search results within minutes.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">Authorization</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Read-Only Access is Sufficient</AlertTitle>
|
||||
<AlertDescription>
|
||||
You only need a read-only API key for this connector to work. This limits the permissions to just reading your Linear data.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Step 1: Create an API key</h4>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Log in to your Linear account</li>
|
||||
<li>Navigate to <a href="https://linear.app/settings/api" target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-4">https://linear.app/settings/api</a> in your browser.</li>
|
||||
<li>Alternatively, click on your profile picture → Settings → API</li>
|
||||
<li>Click the <strong>+ New API key</strong> button.</li>
|
||||
<li>Enter a description for your key (like "Search Connector").</li>
|
||||
<li>Select "Read-only" as the permission.</li>
|
||||
<li>Click <strong>Create</strong> to generate the API key.</li>
|
||||
<li>Copy the generated API key that starts with 'lin_api_' as it will only be shown once.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Linear Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Step 2: Grant necessary access</h4>
|
||||
<p className="text-muted-foreground mb-3">
|
||||
The API key will have access to all issues and comments that your user account can see. If you're creating the key as an admin, it will have access to all issues in the workspace.
|
||||
</p>
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Data Privacy</AlertTitle>
|
||||
<AlertDescription>
|
||||
Only issues and comments will be indexed. Linear attachments and linked files are not indexed by this connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Navigate to the Connector Dashboard and select the <strong>Linear</strong> Connector.</li>
|
||||
<li>Place the <strong>API Key</strong> in the form field.</li>
|
||||
<li>Click <strong>Connect</strong> to establish the connection.</li>
|
||||
<li>Once connected, your Linear issues will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>What Gets Indexed</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="mb-2">The Linear connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5">
|
||||
<li>Issue titles and identifiers (e.g., PROJ-123)</li>
|
||||
<li>Issue descriptions</li>
|
||||
<li>Issue comments</li>
|
||||
<li>Issue status and metadata</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Linear API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="lin_api_..." {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Linear API Key will be encrypted and stored securely. It typically
|
||||
starts with "lin_api_".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Linear
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Linear integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through all your Linear issues and comments</li>
|
||||
<li>Access issue titles, descriptions, and full discussion threads</li>
|
||||
<li>Connect your team's project management directly to your search space</li>
|
||||
<li>Keep your search results up-to-date with latest Linear content</li>
|
||||
<li>Index your Linear issues for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Linear Connector Documentation</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Linear connector to index your project management
|
||||
data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Linear connector uses the Linear GraphQL API to fetch all issues and
|
||||
comments that the API key has access to within a workspace.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>
|
||||
For follow up indexing runs, the connector retrieves issues and comments that
|
||||
have been updated since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run periodically, so updates should appear in your
|
||||
search results within minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Authorization
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Read-Only Access is Sufficient</AlertTitle>
|
||||
<AlertDescription>
|
||||
You only need a read-only API key for this connector to work. This limits
|
||||
the permissions to just reading your Linear data.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Step 1: Create an API key</h4>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Log in to your Linear account</li>
|
||||
<li>
|
||||
Navigate to{" "}
|
||||
<a
|
||||
href="https://linear.app/settings/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
https://linear.app/settings/api
|
||||
</a>{" "}
|
||||
in your browser.
|
||||
</li>
|
||||
<li>Alternatively, click on your profile picture → Settings → API</li>
|
||||
<li>
|
||||
Click the <strong>+ New API key</strong> button.
|
||||
</li>
|
||||
<li>Enter a description for your key (like "Search Connector").</li>
|
||||
<li>Select "Read-only" as the permission.</li>
|
||||
<li>
|
||||
Click <strong>Create</strong> to generate the API key.
|
||||
</li>
|
||||
<li>
|
||||
Copy the generated API key that starts with 'lin_api_' as it will only
|
||||
be shown once.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Step 2: Grant necessary access</h4>
|
||||
<p className="text-muted-foreground mb-3">
|
||||
The API key will have access to all issues and comments that your user
|
||||
account can see. If you're creating the key as an admin, it will have
|
||||
access to all issues in the workspace.
|
||||
</p>
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Data Privacy</AlertTitle>
|
||||
<AlertDescription>
|
||||
Only issues and comments will be indexed. Linear attachments and
|
||||
linked files are not indexed by this connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>Linear</strong>{" "}
|
||||
Connector.
|
||||
</li>
|
||||
<li>
|
||||
Place the <strong>API Key</strong> in the form field.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your Linear issues will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>What Gets Indexed</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="mb-2">The Linear connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5">
|
||||
<li>Issue titles and identifiers (e.g., PROJ-123)</li>
|
||||
<li>Issue descriptions</li>
|
||||
<li>Issue comments</li>
|
||||
<li>Issue status and metadata</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,207 +1,193 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const linkupApiFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type LinkupApiFormValues = z.infer<typeof linkupApiFormSchema>;
|
||||
|
||||
export default function LinkupApiPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<LinkupApiFormValues>({
|
||||
resolver: zodResolver(linkupApiFormSchema),
|
||||
defaultValues: {
|
||||
name: "Linkup API Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
// Initialize the form
|
||||
const form = useForm<LinkupApiFormValues>({
|
||||
resolver: zodResolver(linkupApiFormSchema),
|
||||
defaultValues: {
|
||||
name: "Linkup API Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: LinkupApiFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "LINKUP_API",
|
||||
config: {
|
||||
LINKUP_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: LinkupApiFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "LINKUP_API",
|
||||
config: {
|
||||
LINKUP_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Linkup API connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
toast.success("Linkup API connector created successfully!");
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Linkup API</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Linkup API to enhance your search capabilities with AI-powered search results.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Linkup API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://linkup.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
linkup.so
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Linkup API Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Linkup API</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Linkup API to enhance your search capabilities with AI-powered search
|
||||
results.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Linkup API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://linkup.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
linkup.so
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Linkup API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your Linkup API key"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Linkup API Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>A friendly name to identify this connector.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Linkup API
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Linkup API:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>AI-powered search results tailored to your queries</li>
|
||||
<li>Real-time information from the web</li>
|
||||
<li>Enhanced search capabilities for your projects</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Linkup API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Enter your Linkup API key" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Linkup API
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Linkup API:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>AI-powered search results tailored to your queries</li>
|
||||
<li>Real-time information from the web</li>
|
||||
<li>Enhanced search capabilities for your projects</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,317 +1,364 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const notionConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
integration_token: z.string().min(10, {
|
||||
message: "Notion Integration Token is required and must be valid.",
|
||||
}),
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
integration_token: z.string().min(10, {
|
||||
message: "Notion Integration Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type NotionConnectorFormValues = z.infer<typeof notionConnectorFormSchema>;
|
||||
|
||||
export default function NotionConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<NotionConnectorFormValues>({
|
||||
resolver: zodResolver(notionConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Notion Connector",
|
||||
integration_token: "",
|
||||
},
|
||||
});
|
||||
// Initialize the form
|
||||
const form = useForm<NotionConnectorFormValues>({
|
||||
resolver: zodResolver(notionConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Notion Connector",
|
||||
integration_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: NotionConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "NOTION_CONNECTOR",
|
||||
config: {
|
||||
NOTION_INTEGRATION_TOKEN: values.integration_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: NotionConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "NOTION_CONNECTOR",
|
||||
config: {
|
||||
NOTION_INTEGRATION_TOKEN: values.integration_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Notion connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
toast.success("Notion connector created successfully!");
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Notion Workspace</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Notion to search and retrieve information from your workspace pages and databases. This connector can index your Notion content for search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Notion Integration Token Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Notion Integration Token to use this connector. You can create a Notion integration and get the token from{" "}
|
||||
<a
|
||||
href="https://www.notion.so/my-integrations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Notion Integrations Dashboard
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Notion Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="integration_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notion Integration Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="ntn_.."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Notion Integration Token will be encrypted and stored securely. It typically starts with "ntn_".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Notion Workspace</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Notion to search and retrieve information from your workspace pages
|
||||
and databases. This connector can index your Notion content for search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Notion Integration Token Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Notion Integration Token to use this connector. You can create a
|
||||
Notion integration and get the token from{" "}
|
||||
<a
|
||||
href="https://www.notion.so/my-integrations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Notion Integrations Dashboard
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Notion
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Notion integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through your Notion pages and databases</li>
|
||||
<li>Access documents, wikis, and knowledge bases</li>
|
||||
<li>Connect your team's knowledge directly to your search space</li>
|
||||
<li>Keep your search results up-to-date with latest Notion content</li>
|
||||
<li>Index your Notion documents for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Notion Connector Documentation</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Notion connector to index your workspace data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Notion connector uses the Notion search API to fetch all pages that the connector has access to within a workspace.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>For follow up indexing runs, the connector only retrieves pages that have been updated since the last indexing attempt.</li>
|
||||
<li>Indexing is configured to run every <strong>10 minutes</strong>, so page updates should appear within 10 minutes.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">Authorization</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>No Admin Access Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
There's no requirement to be an Admin to share information with an integration. Any member can share pages and databases with it.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Step 1: Create an integration</h4>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Visit <a href="https://www.notion.com/my-integrations" target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-4">https://www.notion.com/my-integrations</a> in your browser.</li>
|
||||
<li>Click the <strong>+ New integration</strong> button.</li>
|
||||
<li>Name the integration (something like "Search Connector" could work).</li>
|
||||
<li>Select "Read content" as the only capability required.</li>
|
||||
<li>Click <strong>Submit</strong> to create the integration.</li>
|
||||
<li>On the next page, you'll find your Notion integration token. Make a copy of it as you'll need it to configure the connector.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Notion Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Step 2: Share pages/databases with your integration</h4>
|
||||
<p className="text-muted-foreground mb-3">
|
||||
To keep your information secure, integrations don't have access to any pages or databases in the workspace at first.
|
||||
You must share specific pages with an integration in order for the connector to access those pages.
|
||||
</p>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Go to the page/database in your workspace.</li>
|
||||
<li>Click the <code>•••</code> on the top right corner of the page.</li>
|
||||
<li>Scroll to the bottom of the pop-up and click <strong>Add connections</strong>.</li>
|
||||
<li>Search for and select the new integration in the <code>Search for connections...</code> menu.</li>
|
||||
<li>
|
||||
<strong>Important:</strong>
|
||||
<ul className="list-disc pl-5 mt-1">
|
||||
<li>If you've added a page, all child pages also become accessible.</li>
|
||||
<li>If you've added a database, all rows (and their children) become accessible.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Navigate to the Connector Dashboard and select the <strong>Notion</strong> Connector.</li>
|
||||
<li>Place the <strong>Integration Token</strong> under <strong>Step 1 Provide Credentials</strong>.</li>
|
||||
<li>Click <strong>Connect</strong> to establish the connection.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Indexing Behavior</AlertTitle>
|
||||
<AlertDescription>
|
||||
The Notion connector currently indexes everything it has access to. If you want to limit specific content being indexed, simply unshare the database from Notion with the integration.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="integration_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notion Integration Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="ntn_.." {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Notion Integration Token will be encrypted and stored securely. It
|
||||
typically starts with "ntn_".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Notion
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Notion integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through your Notion pages and databases</li>
|
||||
<li>Access documents, wikis, and knowledge bases</li>
|
||||
<li>Connect your team's knowledge directly to your search space</li>
|
||||
<li>Keep your search results up-to-date with latest Notion content</li>
|
||||
<li>Index your Notion documents for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Notion Connector Documentation</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Notion connector to index your workspace data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Notion connector uses the Notion search API to fetch all pages that the
|
||||
connector has access to within a workspace.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>
|
||||
For follow up indexing runs, the connector only retrieves pages that have been
|
||||
updated since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run every <strong>10 minutes</strong>, so page
|
||||
updates should appear within 10 minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Authorization
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>No Admin Access Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
There's no requirement to be an Admin to share information with an
|
||||
integration. Any member can share pages and databases with it.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Step 1: Create an integration</h4>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Visit{" "}
|
||||
<a
|
||||
href="https://www.notion.com/my-integrations"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
https://www.notion.com/my-integrations
|
||||
</a>{" "}
|
||||
in your browser.
|
||||
</li>
|
||||
<li>
|
||||
Click the <strong>+ New integration</strong> button.
|
||||
</li>
|
||||
<li>
|
||||
Name the integration (something like "Search Connector" could work).
|
||||
</li>
|
||||
<li>Select "Read content" as the only capability required.</li>
|
||||
<li>
|
||||
Click <strong>Submit</strong> to create the integration.
|
||||
</li>
|
||||
<li>
|
||||
On the next page, you'll find your Notion integration token. Make a
|
||||
copy of it as you'll need it to configure the connector.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">
|
||||
Step 2: Share pages/databases with your integration
|
||||
</h4>
|
||||
<p className="text-muted-foreground mb-3">
|
||||
To keep your information secure, integrations don't have access to any
|
||||
pages or databases in the workspace at first. You must share specific
|
||||
pages with an integration in order for the connector to access those
|
||||
pages.
|
||||
</p>
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Go to the page/database in your workspace.</li>
|
||||
<li>
|
||||
Click the <code>•••</code> on the top right corner of the page.
|
||||
</li>
|
||||
<li>
|
||||
Scroll to the bottom of the pop-up and click{" "}
|
||||
<strong>Add connections</strong>.
|
||||
</li>
|
||||
<li>
|
||||
Search for and select the new integration in the{" "}
|
||||
<code>Search for connections...</code> menu.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Important:</strong>
|
||||
<ul className="list-disc pl-5 mt-1">
|
||||
<li>
|
||||
If you've added a page, all child pages also become accessible.
|
||||
</li>
|
||||
<li>
|
||||
If you've added a database, all rows (and their children) become
|
||||
accessible.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>Notion</strong>{" "}
|
||||
Connector.
|
||||
</li>
|
||||
<li>
|
||||
Place the <strong>Integration Token</strong> under{" "}
|
||||
<strong>Step 1 Provide Credentials</strong>.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Indexing Behavior</AlertTitle>
|
||||
<AlertDescription>
|
||||
The Notion connector currently indexes everything it has access to. If you
|
||||
want to limit specific content being indexed, simply unshare the database
|
||||
from Notion with the integration.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,408 +1,370 @@
|
|||
"use client";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
IconBook,
|
||||
IconBrandDiscord,
|
||||
IconBrandGithub,
|
||||
IconBrandNotion,
|
||||
IconBrandSlack,
|
||||
IconBrandWindows,
|
||||
IconBrandZoom,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconMail,
|
||||
IconWorldWww,
|
||||
IconTicket,
|
||||
IconLayoutKanban,
|
||||
IconLinkPlus,
|
||||
IconBook,
|
||||
IconBrandDiscord,
|
||||
IconBrandGithub,
|
||||
IconBrandNotion,
|
||||
IconBrandSlack,
|
||||
IconBrandWindows,
|
||||
IconBrandZoom,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconLayoutKanban,
|
||||
IconLinkPlus,
|
||||
IconMail,
|
||||
IconTicket,
|
||||
IconWorldWww,
|
||||
} from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion, type Variants } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
|
||||
// Define the Connector type
|
||||
interface Connector {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
status: "available" | "coming-soon" | "connected";
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
status: "available" | "coming-soon" | "connected";
|
||||
}
|
||||
|
||||
interface ConnectorCategory {
|
||||
id: string;
|
||||
title: string;
|
||||
connectors: Connector[];
|
||||
id: string;
|
||||
title: string;
|
||||
connectors: Connector[];
|
||||
}
|
||||
|
||||
// Define connector categories and their connectors
|
||||
const connectorCategories: ConnectorCategory[] = [
|
||||
{
|
||||
id: "search-engines",
|
||||
title: "Search Engines",
|
||||
connectors: [
|
||||
{
|
||||
id: "tavily-api",
|
||||
title: "Tavily API",
|
||||
description: "Search the web using the Tavily API",
|
||||
icon: <IconWorldWww className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "linkup-api",
|
||||
title: "Linkup API",
|
||||
description: "Search the web using the Linkup API",
|
||||
icon: <IconLinkPlus className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "team-chats",
|
||||
title: "Team Chats",
|
||||
connectors: [
|
||||
{
|
||||
id: "slack-connector",
|
||||
title: "Slack",
|
||||
description:
|
||||
"Connect to your Slack workspace to access messages and channels.",
|
||||
icon: <IconBrandSlack className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "ms-teams",
|
||||
title: "Microsoft Teams",
|
||||
description:
|
||||
"Connect to Microsoft Teams to access your team's conversations.",
|
||||
icon: <IconBrandWindows className="h-6 w-6" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
{
|
||||
id: "discord-connector",
|
||||
title: "Discord",
|
||||
description:
|
||||
"Connect to Discord servers to access messages and channels.",
|
||||
icon: <IconBrandDiscord className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "project-management",
|
||||
title: "Project Management",
|
||||
connectors: [
|
||||
{
|
||||
id: "linear-connector",
|
||||
title: "Linear",
|
||||
description:
|
||||
"Connect to Linear to search issues, comments and project data.",
|
||||
icon: <IconLayoutKanban className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "jira-connector",
|
||||
title: "Jira",
|
||||
description:
|
||||
"Connect to Jira to search issues, tickets and project data.",
|
||||
icon: <IconTicket className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "knowledge-bases",
|
||||
title: "Knowledge Bases",
|
||||
connectors: [
|
||||
{
|
||||
id: "notion-connector",
|
||||
title: "Notion",
|
||||
description:
|
||||
"Connect to your Notion workspace to access pages and databases.",
|
||||
icon: <IconBrandNotion className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "github-connector",
|
||||
title: "GitHub",
|
||||
description:
|
||||
"Connect a GitHub PAT to index code and docs from accessible repositories.",
|
||||
icon: <IconBrandGithub className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "confluence-connector",
|
||||
title: "Confluence",
|
||||
description:
|
||||
"Connect to Confluence to search pages, comments and documentation.",
|
||||
icon: <IconBook className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "communication",
|
||||
title: "Communication",
|
||||
connectors: [
|
||||
{
|
||||
id: "gmail",
|
||||
title: "Gmail",
|
||||
description: "Connect to your Gmail account to access emails.",
|
||||
icon: <IconMail className="h-6 w-6" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
{
|
||||
id: "zoom",
|
||||
title: "Zoom",
|
||||
description:
|
||||
"Connect to Zoom to access meeting recordings and transcripts.",
|
||||
icon: <IconBrandZoom className="h-6 w-6" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "search-engines",
|
||||
title: "Search Engines",
|
||||
connectors: [
|
||||
{
|
||||
id: "tavily-api",
|
||||
title: "Tavily API",
|
||||
description: "Search the web using the Tavily API",
|
||||
icon: <IconWorldWww className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "linkup-api",
|
||||
title: "Linkup API",
|
||||
description: "Search the web using the Linkup API",
|
||||
icon: <IconLinkPlus className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "team-chats",
|
||||
title: "Team Chats",
|
||||
connectors: [
|
||||
{
|
||||
id: "slack-connector",
|
||||
title: "Slack",
|
||||
description: "Connect to your Slack workspace to access messages and channels.",
|
||||
icon: <IconBrandSlack className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "ms-teams",
|
||||
title: "Microsoft Teams",
|
||||
description: "Connect to Microsoft Teams to access your team's conversations.",
|
||||
icon: <IconBrandWindows className="h-6 w-6" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
{
|
||||
id: "discord-connector",
|
||||
title: "Discord",
|
||||
description: "Connect to Discord servers to access messages and channels.",
|
||||
icon: <IconBrandDiscord className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "project-management",
|
||||
title: "Project Management",
|
||||
connectors: [
|
||||
{
|
||||
id: "linear-connector",
|
||||
title: "Linear",
|
||||
description: "Connect to Linear to search issues, comments and project data.",
|
||||
icon: <IconLayoutKanban className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "jira-connector",
|
||||
title: "Jira",
|
||||
description: "Connect to Jira to search issues, tickets and project data.",
|
||||
icon: <IconTicket className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "knowledge-bases",
|
||||
title: "Knowledge Bases",
|
||||
connectors: [
|
||||
{
|
||||
id: "notion-connector",
|
||||
title: "Notion",
|
||||
description: "Connect to your Notion workspace to access pages and databases.",
|
||||
icon: <IconBrandNotion className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "github-connector",
|
||||
title: "GitHub",
|
||||
description: "Connect a GitHub PAT to index code and docs from accessible repositories.",
|
||||
icon: <IconBrandGithub className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "confluence-connector",
|
||||
title: "Confluence",
|
||||
description: "Connect to Confluence to search pages, comments and documentation.",
|
||||
icon: <IconBook className="h-6 w-6" />,
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "communication",
|
||||
title: "Communication",
|
||||
connectors: [
|
||||
{
|
||||
id: "gmail",
|
||||
title: "Gmail",
|
||||
description: "Connect to your Gmail account to access emails.",
|
||||
icon: <IconMail className="h-6 w-6" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
{
|
||||
id: "zoom",
|
||||
title: "Zoom",
|
||||
description: "Connect to Zoom to access meeting recordings and transcripts.",
|
||||
icon: <IconBrandZoom className="h-6 w-6" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Animation variants
|
||||
const fadeIn = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { duration: 0.4 } },
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { duration: 0.4 } },
|
||||
};
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cardVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 20,
|
||||
},
|
||||
},
|
||||
hover: {
|
||||
scale: 1.02,
|
||||
boxShadow:
|
||||
"0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 10,
|
||||
},
|
||||
},
|
||||
const cardVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 20,
|
||||
},
|
||||
},
|
||||
hover: {
|
||||
scale: 1.02,
|
||||
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function ConnectorsPage() {
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [expandedCategories, setExpandedCategories] = useState<string[]>([
|
||||
"search-engines",
|
||||
"knowledge-bases",
|
||||
"project-management",
|
||||
"team-chats",
|
||||
]);
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [expandedCategories, setExpandedCategories] = useState<string[]>([
|
||||
"search-engines",
|
||||
"knowledge-bases",
|
||||
"project-management",
|
||||
"team-chats",
|
||||
]);
|
||||
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
setExpandedCategories((prev) =>
|
||||
prev.includes(categoryId)
|
||||
? prev.filter((id) => id !== categoryId)
|
||||
: [...prev, categoryId],
|
||||
);
|
||||
};
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
setExpandedCategories((prev) =>
|
||||
prev.includes(categoryId) ? prev.filter((id) => id !== categoryId) : [...prev, categoryId]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-12 max-w-6xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
}}
|
||||
className="mb-12 text-center"
|
||||
>
|
||||
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-indigo-500 to-purple-500 bg-clip-text text-transparent">
|
||||
Connect Your Tools
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-3 text-lg max-w-2xl mx-auto">
|
||||
Integrate with your favorite services to enhance your research
|
||||
capabilities.
|
||||
</p>
|
||||
</motion.div>
|
||||
return (
|
||||
<div className="container mx-auto py-12 max-w-6xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
}}
|
||||
className="mb-12 text-center"
|
||||
>
|
||||
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-indigo-500 to-purple-500 bg-clip-text text-transparent">
|
||||
Connect Your Tools
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-3 text-lg max-w-2xl mx-auto">
|
||||
Integrate with your favorite services to enhance your research capabilities.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="space-y-8"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
{connectorCategories.map((category) => (
|
||||
<motion.div
|
||||
key={category.id}
|
||||
variants={fadeIn}
|
||||
className="rounded-lg border bg-card text-card-foreground shadow-sm"
|
||||
>
|
||||
<Collapsible
|
||||
open={expandedCategories.includes(category.id)}
|
||||
onOpenChange={() => toggleCategory(category.id)}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between space-x-4 p-4">
|
||||
<h3 className="text-xl font-semibold">{category.title}</h3>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-9 p-0 hover:bg-muted"
|
||||
>
|
||||
<motion.div
|
||||
animate={{
|
||||
rotate: expandedCategories.includes(category.id)
|
||||
? 180
|
||||
: 0,
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
<IconChevronDown className="h-5 w-5" />
|
||||
</motion.div>
|
||||
<span className="sr-only">Toggle</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<motion.div
|
||||
className="space-y-8"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
{connectorCategories.map((category) => (
|
||||
<motion.div
|
||||
key={category.id}
|
||||
variants={fadeIn}
|
||||
className="rounded-lg border bg-card text-card-foreground shadow-sm"
|
||||
>
|
||||
<Collapsible
|
||||
open={expandedCategories.includes(category.id)}
|
||||
onOpenChange={() => toggleCategory(category.id)}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center justify-between space-x-4 p-4">
|
||||
<h3 className="text-xl font-semibold">{category.title}</h3>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="w-9 p-0 hover:bg-muted">
|
||||
<motion.div
|
||||
animate={{
|
||||
rotate: expandedCategories.includes(category.id) ? 180 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
<IconChevronDown className="h-5 w-5" />
|
||||
</motion.div>
|
||||
<span className="sr-only">Toggle</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 p-4"
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
>
|
||||
{category.connectors.map((connector) => (
|
||||
<motion.div
|
||||
key={connector.id}
|
||||
variants={cardVariants}
|
||||
whileHover="hover"
|
||||
className="col-span-1"
|
||||
>
|
||||
<Card className="h-full flex flex-col overflow-hidden border-transparent transition-all duration-200 hover:border-primary/50">
|
||||
<CardHeader className="flex-row items-center gap-4 pb-2">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 dark:bg-primary/20">
|
||||
<motion.div
|
||||
whileHover={{ rotate: 5, scale: 1.1 }}
|
||||
className="text-primary"
|
||||
>
|
||||
{connector.icon}
|
||||
</motion.div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">
|
||||
{connector.title}
|
||||
</h3>
|
||||
{connector.status === "coming-soon" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-300 border-amber-200 dark:border-amber-800"
|
||||
>
|
||||
Coming soon
|
||||
</Badge>
|
||||
)}
|
||||
{connector.status === "connected" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-green-100 dark:bg-green-950 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800"
|
||||
>
|
||||
Connected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CollapsibleContent>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 p-4"
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
>
|
||||
{category.connectors.map((connector) => (
|
||||
<motion.div
|
||||
key={connector.id}
|
||||
variants={cardVariants}
|
||||
whileHover="hover"
|
||||
className="col-span-1"
|
||||
>
|
||||
<Card className="h-full flex flex-col overflow-hidden border-transparent transition-all duration-200 hover:border-primary/50">
|
||||
<CardHeader className="flex-row items-center gap-4 pb-2">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 dark:bg-primary/20">
|
||||
<motion.div
|
||||
whileHover={{ rotate: 5, scale: 1.1 }}
|
||||
className="text-primary"
|
||||
>
|
||||
{connector.icon}
|
||||
</motion.div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">{connector.title}</h3>
|
||||
{connector.status === "coming-soon" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-300 border-amber-200 dark:border-amber-800"
|
||||
>
|
||||
Coming soon
|
||||
</Badge>
|
||||
)}
|
||||
{connector.status === "connected" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-green-100 dark:bg-green-950 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800"
|
||||
>
|
||||
Connected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{connector.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardContent className="pb-4">
|
||||
<p className="text-sm text-muted-foreground">{connector.description}</p>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="mt-auto pt-2">
|
||||
{connector.status === "available" && (
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`}
|
||||
className="w-full"
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full group"
|
||||
>
|
||||
<span>Connect</span>
|
||||
<motion.div
|
||||
className="ml-1"
|
||||
initial={{ x: 0 }}
|
||||
whileHover={{ x: 3 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 10,
|
||||
}}
|
||||
>
|
||||
<IconChevronRight className="h-4 w-4" />
|
||||
</motion.div>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{connector.status === "coming-soon" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled
|
||||
className="w-full opacity-70"
|
||||
>
|
||||
Coming Soon
|
||||
</Button>
|
||||
)}
|
||||
{connector.status === "connected" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-green-500 text-green-600 hover:bg-green-50 dark:hover:bg-green-950"
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
<CardFooter className="mt-auto pt-2">
|
||||
{connector.status === "available" && (
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`}
|
||||
className="w-full"
|
||||
>
|
||||
<Button variant="default" className="w-full group">
|
||||
<span>Connect</span>
|
||||
<motion.div
|
||||
className="ml-1"
|
||||
initial={{ x: 0 }}
|
||||
whileHover={{ x: 3 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 10,
|
||||
}}
|
||||
>
|
||||
<IconChevronRight className="h-4 w-4" />
|
||||
</motion.div>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{connector.status === "coming-soon" && (
|
||||
<Button variant="outline" disabled className="w-full opacity-70">
|
||||
Coming Soon
|
||||
</Button>
|
||||
)}
|
||||
{connector.status === "connected" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-green-500 text-green-600 hover:bg-green-50 dark:hover:bg-green-950"
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,207 +1,193 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const serperApiFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type SerperApiFormValues = z.infer<typeof serperApiFormSchema>;
|
||||
|
||||
export default function SerperApiPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<SerperApiFormValues>({
|
||||
resolver: zodResolver(serperApiFormSchema),
|
||||
defaultValues: {
|
||||
name: "Serper API Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
// Initialize the form
|
||||
const form = useForm<SerperApiFormValues>({
|
||||
resolver: zodResolver(serperApiFormSchema),
|
||||
defaultValues: {
|
||||
name: "Serper API Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: SerperApiFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "SERPER_API",
|
||||
config: {
|
||||
SERPER_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: SerperApiFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "SERPER_API",
|
||||
config: {
|
||||
SERPER_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Serper API connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
toast.success("Serper API connector created successfully!");
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Serper API</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Serper API to enhance your search capabilities with Google search results.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Serper API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://serper.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
serper.dev
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Serper API Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Serper API</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Serper API to enhance your search capabilities with Google search
|
||||
results.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Serper API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://serper.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
serper.dev
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Serper API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your Serper API key"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Serper API Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>A friendly name to identify this connector.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Serper API
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Serper API:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Access to Google search results directly in your research</li>
|
||||
<li>Real-time information from the web</li>
|
||||
<li>Enhanced search capabilities for your projects</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Serper API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Enter your Serper API key" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Serper API
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Serper API:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Access to Google search results directly in your research</li>
|
||||
<li>Real-time information from the web</li>
|
||||
<li>Enhanced search capabilities for your projects</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,270 +1,285 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const slackConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
bot_token: z.string().min(10, {
|
||||
message: "Bot User OAuth Token is required and must be valid.",
|
||||
}),
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
bot_token: z.string().min(10, {
|
||||
message: "Bot User OAuth Token is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type SlackConnectorFormValues = z.infer<typeof slackConnectorFormSchema>;
|
||||
|
||||
export default function SlackConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<SlackConnectorFormValues>({
|
||||
resolver: zodResolver(slackConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Slack Connector",
|
||||
bot_token: "",
|
||||
},
|
||||
});
|
||||
// Initialize the form
|
||||
const form = useForm<SlackConnectorFormValues>({
|
||||
resolver: zodResolver(slackConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Slack Connector",
|
||||
bot_token: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: SlackConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "SLACK_CONNECTOR",
|
||||
config: {
|
||||
SLACK_BOT_TOKEN: values.bot_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: SlackConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "SLACK_CONNECTOR",
|
||||
config: {
|
||||
SLACK_BOT_TOKEN: values.bot_token,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Slack connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
toast.success("Slack connector created successfully!");
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Slack Workspace</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Slack to search and retrieve information from your workspace channels and conversations. This connector can index your Slack messages for search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Bot User OAuth Token Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Slack Bot User OAuth Token to use this connector. You can create a Slack app and get the token from{" "}
|
||||
<a
|
||||
href="https://api.slack.com/apps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Slack API Dashboard
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Slack Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Tabs defaultValue="connect" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bot_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slack Bot User OAuth Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="xoxb-..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Bot User OAuth Token will be encrypted and stored securely. It typically starts with "xoxb-".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<TabsContent value="connect">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Slack Workspace</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Slack to search and retrieve information from your workspace
|
||||
channels and conversations. This connector can index your Slack messages for
|
||||
search.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Bot User OAuth Token Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Slack Bot User OAuth Token to use this connector. You can create a
|
||||
Slack app and get the token from{" "}
|
||||
<a
|
||||
href="https://api.slack.com/apps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Slack API Dashboard
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Slack
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Slack integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through your Slack channels and conversations</li>
|
||||
<li>Access historical messages and shared files</li>
|
||||
<li>Connect your team's knowledge directly to your search space</li>
|
||||
<li>Keep your search results up-to-date with latest communications</li>
|
||||
<li>Index your Slack messages for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Slack Connector Documentation</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Slack connector to index your workspace data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Slack connector indexes all public channels for a given workspace.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>Upcoming: Support for private channels by tagging/adding the Slack Bot to private channels.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">Authorization</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Admin Access Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You must be an admin of the Slack workspace to set up the connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Navigate and sign in to <a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-4">https://api.slack.com/apps</a>.</li>
|
||||
<li>
|
||||
Create a new Slack app:
|
||||
<ul className="list-disc pl-5 mt-1">
|
||||
<li>Click the <strong>Create New App</strong> button in the top right.</li>
|
||||
<li>Select <strong>From an app manifest</strong> option.</li>
|
||||
<li>Select the relevant workspace from the dropdown and click <strong>Next</strong>.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
Select the "YAML" tab, paste the following manifest into the text box, and click <strong>Next</strong>:
|
||||
<div className="bg-muted p-4 rounded-md mt-2 overflow-x-auto">
|
||||
<pre className="text-xs">
|
||||
{`display_information:
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Slack Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bot_token"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slack Bot User OAuth Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="xoxb-..." {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your Bot User OAuth Token will be encrypted and stored securely. It
|
||||
typically starts with "xoxb-".
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Slack
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Slack integration:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>Search through your Slack channels and conversations</li>
|
||||
<li>Access historical messages and shared files</li>
|
||||
<li>Connect your team's knowledge directly to your search space</li>
|
||||
<li>Keep your search results up-to-date with latest communications</li>
|
||||
<li>Index your Slack messages for enhanced search capabilities</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documentation">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Slack Connector Documentation</CardTitle>
|
||||
<CardDescription>
|
||||
Learn how to set up and use the Slack connector to index your workspace data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The Slack connector indexes all public channels for a given workspace.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||
<li>
|
||||
Upcoming: Support for private channels by tagging/adding the Slack Bot to
|
||||
private channels.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="authorization">
|
||||
<AccordionTrigger className="text-lg font-medium">
|
||||
Authorization
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Admin Access Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You must be an admin of the Slack workspace to set up the connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Navigate and sign in to{" "}
|
||||
<a
|
||||
href="https://api.slack.com/apps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
https://api.slack.com/apps
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
Create a new Slack app:
|
||||
<ul className="list-disc pl-5 mt-1">
|
||||
<li>
|
||||
Click the <strong>Create New App</strong> button in the top right.
|
||||
</li>
|
||||
<li>
|
||||
Select <strong>From an app manifest</strong> option.
|
||||
</li>
|
||||
<li>
|
||||
Select the relevant workspace from the dropdown and click{" "}
|
||||
<strong>Next</strong>.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
Select the "YAML" tab, paste the following manifest into the text box, and
|
||||
click <strong>Next</strong>:
|
||||
<div className="bg-muted p-4 rounded-md mt-2 overflow-x-auto">
|
||||
<pre className="text-xs">
|
||||
{`display_information:
|
||||
name: SlackConnector
|
||||
description: ReadOnly Connector for indexing
|
||||
features:
|
||||
|
|
@ -287,65 +302,94 @@ settings:
|
|||
org_deploy_enabled: false
|
||||
socket_mode_enabled: false
|
||||
token_rotation_enabled: false`}
|
||||
</pre>
|
||||
</div>
|
||||
</li>
|
||||
<li>Click the <strong>Create</strong> button.</li>
|
||||
<li>In the app page, navigate to the <strong>OAuth & Permissions</strong> tab under the <strong>Features</strong> header.</li>
|
||||
<li>Copy the <strong>Bot User OAuth Token</strong>, this will be used to access Slack.</li>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>Navigate to the Connector Dashboard and select the <strong>Slack</strong> Connector.</li>
|
||||
<li>Place the <strong>Bot User OAuth Token</strong> under <strong>Step 1 Provide Credentials</strong>.</li>
|
||||
<li>Click <strong>Connect</strong> to establish the connection.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Important: Invite Bot to Channels</AlertTitle>
|
||||
<AlertDescription>
|
||||
After connecting, you must invite the bot to each channel you want to index. In each Slack channel, type:
|
||||
<pre className="mt-2 bg-background p-2 rounded-md text-xs">/invite @YourBotName</pre>
|
||||
<p className="mt-2">Without this step, you'll get a "not_in_channel" error when the connector tries to access channel messages.</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert className="bg-muted mt-4">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>First Indexing</AlertTitle>
|
||||
<AlertDescription>
|
||||
The first indexing pulls all of the public channels and takes longer than future updates. Only channels where the bot has been invited will be fully indexed.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="mt-4">
|
||||
<h4 className="font-medium mb-2">Troubleshooting:</h4>
|
||||
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
|
||||
<li>
|
||||
<strong>not_in_channel error:</strong> If you see this error in logs, it means the bot hasn't been invited to a channel it's trying to access. Use the <code>/invite @YourBotName</code> command in that channel.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Alternative approach:</strong> You can add the <code>chat:write.public</code> scope to your Slack app to allow it to access public channels without an explicit invitation.
|
||||
</li>
|
||||
<li>
|
||||
<strong>For private channels:</strong> The bot must always be invited using the <code>/invite</code> command.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
</pre>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
Click the <strong>Create</strong> button.
|
||||
</li>
|
||||
<li>
|
||||
In the app page, navigate to the <strong>OAuth & Permissions</strong> tab
|
||||
under the <strong>Features</strong> header.
|
||||
</li>
|
||||
<li>
|
||||
Copy the <strong>Bot User OAuth Token</strong>, this will be used to
|
||||
access Slack.
|
||||
</li>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="indexing">
|
||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
<ol className="list-decimal pl-5 space-y-3">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>Slack</strong>{" "}
|
||||
Connector.
|
||||
</li>
|
||||
<li>
|
||||
Place the <strong>Bot User OAuth Token</strong> under{" "}
|
||||
<strong>Step 1 Provide Credentials</strong>.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Important: Invite Bot to Channels</AlertTitle>
|
||||
<AlertDescription>
|
||||
After connecting, you must invite the bot to each channel you want to
|
||||
index. In each Slack channel, type:
|
||||
<pre className="mt-2 bg-background p-2 rounded-md text-xs">
|
||||
/invite @YourBotName
|
||||
</pre>
|
||||
<p className="mt-2">
|
||||
Without this step, you'll get a "not_in_channel" error when the
|
||||
connector tries to access channel messages.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert className="bg-muted mt-4">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>First Indexing</AlertTitle>
|
||||
<AlertDescription>
|
||||
The first indexing pulls all of the public channels and takes longer than
|
||||
future updates. Only channels where the bot has been invited will be fully
|
||||
indexed.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="mt-4">
|
||||
<h4 className="font-medium mb-2">Troubleshooting:</h4>
|
||||
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
|
||||
<li>
|
||||
<strong>not_in_channel error:</strong> If you see this error in logs, it
|
||||
means the bot hasn't been invited to a channel it's trying to access.
|
||||
Use the <code>/invite @YourBotName</code> command in that channel.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Alternative approach:</strong> You can add the{" "}
|
||||
<code>chat:write.public</code> scope to your Slack app to allow it to
|
||||
access public channels without an explicit invitation.
|
||||
</li>
|
||||
<li>
|
||||
<strong>For private channels:</strong> The bot must always be invited
|
||||
using the <code>/invite</code> command.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,207 +1,193 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const tavilyApiFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type TavilyApiFormValues = z.infer<typeof tavilyApiFormSchema>;
|
||||
|
||||
export default function TavilyApiPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<TavilyApiFormValues>({
|
||||
resolver: zodResolver(tavilyApiFormSchema),
|
||||
defaultValues: {
|
||||
name: "Tavily API Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
// Initialize the form
|
||||
const form = useForm<TavilyApiFormValues>({
|
||||
resolver: zodResolver(tavilyApiFormSchema),
|
||||
defaultValues: {
|
||||
name: "Tavily API Connector",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: TavilyApiFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "TAVILY_API",
|
||||
config: {
|
||||
TAVILY_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: TavilyApiFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: "TAVILY_API",
|
||||
config: {
|
||||
TAVILY_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: false,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Tavily API connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
toast.success("Tavily API connector created successfully!");
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Tavily API</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Tavily API to enhance your search capabilities with AI-powered search results.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://tavily.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
tavily.com
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Connectors
|
||||
</Button>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Tavily API Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">Connect Tavily API</CardTitle>
|
||||
<CardDescription>
|
||||
Integrate with Tavily API to enhance your search capabilities with AI-powered search
|
||||
results.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
|
||||
<a
|
||||
href="https://tavily.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
tavily.com
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tavily API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your Tavily API key"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Tavily API Connector" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>A friendly name to identify this connector.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Tavily API
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Tavily API:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>AI-powered search results tailored to your queries</li>
|
||||
<li>Real-time information from the web</li>
|
||||
<li>Enhanced search capabilities for your projects</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tavily API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Enter your Tavily API key" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
Connect Tavily API
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||
<h4 className="text-sm font-medium">What you get with Tavily API:</h4>
|
||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||
<li>AI-powered search results tailored to your queries</li>
|
||||
<li>Real-time information from the web</li>
|
||||
<li>Enhanced search capabilities for your projects</li>
|
||||
</ul>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,200 +1,201 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Tag, TagInput } from "emblor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { toast } from "sonner";
|
||||
import { type Tag, TagInput } from "emblor";
|
||||
import { Globe, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
// URL validation regex
|
||||
const urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/;
|
||||
|
||||
export default function WebpageCrawler() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const search_space_id = params.search_space_id as string;
|
||||
|
||||
const [urlTags, setUrlTags] = useState<Tag[]>([]);
|
||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const search_space_id = params.search_space_id as string;
|
||||
|
||||
// Function to validate a URL
|
||||
const isValidUrl = (url: string): boolean => {
|
||||
return urlRegex.test(url);
|
||||
};
|
||||
const [urlTags, setUrlTags] = useState<Tag[]>([]);
|
||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Function to handle URL submission
|
||||
const handleSubmit = async () => {
|
||||
// Validate that we have at least one URL
|
||||
if (urlTags.length === 0) {
|
||||
setError("Please add at least one URL");
|
||||
return;
|
||||
}
|
||||
// Function to validate a URL
|
||||
const isValidUrl = (url: string): boolean => {
|
||||
return urlRegex.test(url);
|
||||
};
|
||||
|
||||
// Validate all URLs
|
||||
const invalidUrls = urlTags.filter(tag => !isValidUrl(tag.text));
|
||||
if (invalidUrls.length > 0) {
|
||||
setError(`Invalid URLs detected: ${invalidUrls.map(tag => tag.text).join(', ')}`);
|
||||
return;
|
||||
}
|
||||
// Function to handle URL submission
|
||||
const handleSubmit = async () => {
|
||||
// Validate that we have at least one URL
|
||||
if (urlTags.length === 0) {
|
||||
setError("Please add at least one URL");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
// Validate all URLs
|
||||
const invalidUrls = urlTags.filter((tag) => !isValidUrl(tag.text));
|
||||
if (invalidUrls.length > 0) {
|
||||
setError(`Invalid URLs detected: ${invalidUrls.map((tag) => tag.text).join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
toast("URL Crawling", {
|
||||
description: "Starting URL crawling process...",
|
||||
});
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Extract URLs from tags
|
||||
const urls = urlTags.map(tag => tag.text);
|
||||
try {
|
||||
toast("URL Crawling", {
|
||||
description: "Starting URL crawling process...",
|
||||
});
|
||||
|
||||
// Make API call to backend
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem("surfsense_bearer_token")}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"document_type": "CRAWLED_URL",
|
||||
"content": urls,
|
||||
"search_space_id": parseInt(search_space_id)
|
||||
}),
|
||||
});
|
||||
// Extract URLs from tags
|
||||
const urls = urlTags.map((tag) => tag.text);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to crawl URLs");
|
||||
}
|
||||
// Make API call to backend
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
document_type: "CRAWLED_URL",
|
||||
content: urls,
|
||||
search_space_id: parseInt(search_space_id),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to crawl URLs");
|
||||
}
|
||||
|
||||
toast("Crawling Successful", {
|
||||
description: "URLs have been submitted for crawling",
|
||||
});
|
||||
await response.json();
|
||||
|
||||
// Redirect to documents page
|
||||
router.push(`/dashboard/${search_space_id}/documents`);
|
||||
} catch (error: any) {
|
||||
setError(error.message || "An error occurred while crawling URLs");
|
||||
toast("Crawling Error", {
|
||||
description: `Error crawling URLs: ${error.message}`,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
toast("Crawling Successful", {
|
||||
description: "URLs have been submitted for crawling",
|
||||
});
|
||||
|
||||
// Function to add a new URL tag
|
||||
const handleAddTag = (text: string) => {
|
||||
// Basic URL validation
|
||||
if (!isValidUrl(text)) {
|
||||
toast("Invalid URL", {
|
||||
description: "Please enter a valid URL",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Redirect to documents page
|
||||
router.push(`/dashboard/${search_space_id}/documents`);
|
||||
} catch (error: any) {
|
||||
setError(error.message || "An error occurred while crawling URLs");
|
||||
toast("Crawling Error", {
|
||||
description: `Error crawling URLs: ${error.message}`,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Check for duplicates
|
||||
if (urlTags.some(tag => tag.text === text)) {
|
||||
toast("Duplicate URL", {
|
||||
description: "This URL has already been added",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Function to add a new URL tag
|
||||
const handleAddTag = (text: string) => {
|
||||
// Basic URL validation
|
||||
if (!isValidUrl(text)) {
|
||||
toast("Invalid URL", {
|
||||
description: "Please enter a valid URL",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the new tag
|
||||
const newTag: Tag = {
|
||||
id: Date.now().toString(),
|
||||
text: text,
|
||||
};
|
||||
// Check for duplicates
|
||||
if (urlTags.some((tag) => tag.text === text)) {
|
||||
toast("Duplicate URL", {
|
||||
description: "This URL has already been added",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setUrlTags([...urlTags, newTag]);
|
||||
};
|
||||
// Add the new tag
|
||||
const newTag: Tag = {
|
||||
id: Date.now().toString(),
|
||||
text: text,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
Add Webpages for Crawling
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter URLs to crawl and add to your document collection
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url-input">Enter URLs to crawl</Label>
|
||||
<TagInput
|
||||
id="url-input"
|
||||
tags={urlTags}
|
||||
setTags={setUrlTags}
|
||||
placeholder="Enter a URL and press Enter"
|
||||
onAddTag={handleAddTag}
|
||||
styleClasses={{
|
||||
inlineTagsContainer:
|
||||
"border-input rounded-lg bg-background shadow-sm shadow-black/5 transition-shadow focus-within:border-ring focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 p-1 gap-1",
|
||||
input: "w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7",
|
||||
tag: {
|
||||
body: "h-7 relative bg-background border border-input hover:bg-background rounded-md font-medium text-xs ps-2 pe-7 flex",
|
||||
closeButton:
|
||||
"absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-foreground",
|
||||
},
|
||||
}}
|
||||
activeTagIndex={activeTagIndex}
|
||||
setActiveTagIndex={setActiveTagIndex}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Add multiple URLs by pressing Enter after each one
|
||||
</p>
|
||||
</div>
|
||||
setUrlTags([...urlTags, newTag]);
|
||||
};
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-500 mt-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
Add Webpages for Crawling
|
||||
</CardTitle>
|
||||
<CardDescription>Enter URLs to crawl and add to your document collection</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url-input">Enter URLs to crawl</Label>
|
||||
<TagInput
|
||||
id="url-input"
|
||||
tags={urlTags}
|
||||
setTags={setUrlTags}
|
||||
placeholder="Enter a URL and press Enter"
|
||||
onAddTag={handleAddTag}
|
||||
styleClasses={{
|
||||
inlineTagsContainer:
|
||||
"border-input rounded-lg bg-background shadow-sm shadow-black/5 transition-shadow focus-within:border-ring focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 p-1 gap-1",
|
||||
input: "w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7",
|
||||
tag: {
|
||||
body: "h-7 relative bg-background border border-input hover:bg-background rounded-md font-medium text-xs ps-2 pe-7 flex",
|
||||
closeButton:
|
||||
"absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-foreground",
|
||||
},
|
||||
}}
|
||||
activeTagIndex={activeTagIndex}
|
||||
setActiveTagIndex={setActiveTagIndex}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Add multiple URLs by pressing Enter after each one
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4 text-sm">
|
||||
<h4 className="font-medium mb-2">Tips for URL crawling:</h4>
|
||||
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
|
||||
<li>Enter complete URLs including http:// or https://</li>
|
||||
<li>Make sure the websites allow crawling</li>
|
||||
<li>Public webpages work best</li>
|
||||
<li>Crawling may take some time depending on the website size</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || urlTags.length === 0}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
'Submit URLs for Crawling'
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{error && <div className="text-sm text-red-500 mt-2">{error}</div>}
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4 text-sm">
|
||||
<h4 className="font-medium mb-2">Tips for URL crawling:</h4>
|
||||
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
|
||||
<li>Enter complete URLs including http:// or https://</li>
|
||||
<li>Make sure the websites allow crawling</li>
|
||||
<li>Public webpages work best</li>
|
||||
<li>Crawling may take some time depending on the website size</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting || urlTags.length === 0}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
"Submit URLs for Crawling"
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,302 +1,304 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Tag, TagInput } from "emblor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { IconBrandYoutube } from "@tabler/icons-react";
|
||||
import { type Tag, TagInput } from "emblor";
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Youtube, Loader2 } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
// YouTube video ID validation regex
|
||||
const youtubeRegex = /^(https:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})$/;
|
||||
const youtubeRegex =
|
||||
/^(https:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})$/;
|
||||
|
||||
export default function YouTubeVideoAdder() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const search_space_id = params.search_space_id as string;
|
||||
|
||||
const [videoTags, setVideoTags] = useState<Tag[]>([]);
|
||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const search_space_id = params.search_space_id as string;
|
||||
|
||||
// Function to validate a YouTube URL
|
||||
const isValidYoutubeUrl = (url: string): boolean => {
|
||||
return youtubeRegex.test(url);
|
||||
};
|
||||
const [videoTags, setVideoTags] = useState<Tag[]>([]);
|
||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Function to extract video ID from URL
|
||||
const extractVideoId = (url: string): string | null => {
|
||||
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
// Function to validate a YouTube URL
|
||||
const isValidYoutubeUrl = (url: string): boolean => {
|
||||
return youtubeRegex.test(url);
|
||||
};
|
||||
|
||||
// Function to handle video URL submission
|
||||
const handleSubmit = async () => {
|
||||
// Validate that we have at least one video URL
|
||||
if (videoTags.length === 0) {
|
||||
setError("Please add at least one YouTube video URL");
|
||||
return;
|
||||
}
|
||||
// Function to extract video ID from URL
|
||||
const extractVideoId = (url: string): string | null => {
|
||||
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
// Validate all URLs
|
||||
const invalidUrls = videoTags.filter(tag => !isValidYoutubeUrl(tag.text));
|
||||
if (invalidUrls.length > 0) {
|
||||
setError(`Invalid YouTube URLs detected: ${invalidUrls.map(tag => tag.text).join(', ')}`);
|
||||
return;
|
||||
}
|
||||
// Function to handle video URL submission
|
||||
const handleSubmit = async () => {
|
||||
// Validate that we have at least one video URL
|
||||
if (videoTags.length === 0) {
|
||||
setError("Please add at least one YouTube video URL");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
// Validate all URLs
|
||||
const invalidUrls = videoTags.filter((tag) => !isValidYoutubeUrl(tag.text));
|
||||
if (invalidUrls.length > 0) {
|
||||
setError(`Invalid YouTube URLs detected: ${invalidUrls.map((tag) => tag.text).join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
toast("YouTube Video Processing", {
|
||||
description: "Starting YouTube video processing...",
|
||||
});
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Extract URLs from tags
|
||||
const videoUrls = videoTags.map(tag => tag.text);
|
||||
try {
|
||||
toast("YouTube Video Processing", {
|
||||
description: "Starting YouTube video processing...",
|
||||
});
|
||||
|
||||
// Make API call to backend
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem("surfsense_bearer_token")}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
"document_type": "YOUTUBE_VIDEO",
|
||||
"content": videoUrls,
|
||||
"search_space_id": parseInt(search_space_id)
|
||||
}),
|
||||
});
|
||||
// Extract URLs from tags
|
||||
const videoUrls = videoTags.map((tag) => tag.text);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to process YouTube videos");
|
||||
}
|
||||
// Make API call to backend
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
document_type: "YOUTUBE_VIDEO",
|
||||
content: videoUrls,
|
||||
search_space_id: parseInt(search_space_id),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to process YouTube videos");
|
||||
}
|
||||
|
||||
toast("Processing Successful", {
|
||||
description: "YouTube videos have been submitted for processing",
|
||||
});
|
||||
await response.json();
|
||||
|
||||
// Redirect to documents page
|
||||
router.push(`/dashboard/${search_space_id}/documents`);
|
||||
} catch (error: any) {
|
||||
setError(error.message || "An error occurred while processing YouTube videos");
|
||||
toast("Processing Error", {
|
||||
description: `Error processing YouTube videos: ${error.message}`,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
toast("Processing Successful", {
|
||||
description: "YouTube videos have been submitted for processing",
|
||||
});
|
||||
|
||||
// Function to add a new video URL tag
|
||||
const handleAddTag = (text: string) => {
|
||||
// Basic URL validation
|
||||
if (!isValidYoutubeUrl(text)) {
|
||||
toast("Invalid YouTube URL", {
|
||||
description: "Please enter a valid YouTube video URL",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Redirect to documents page
|
||||
router.push(`/dashboard/${search_space_id}/documents`);
|
||||
} catch (error: any) {
|
||||
setError(error.message || "An error occurred while processing YouTube videos");
|
||||
toast("Processing Error", {
|
||||
description: `Error processing YouTube videos: ${error.message}`,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Check for duplicates
|
||||
if (videoTags.some(tag => tag.text === text)) {
|
||||
toast("Duplicate URL", {
|
||||
description: "This YouTube video has already been added",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Function to add a new video URL tag
|
||||
const handleAddTag = (text: string) => {
|
||||
// Basic URL validation
|
||||
if (!isValidYoutubeUrl(text)) {
|
||||
toast("Invalid YouTube URL", {
|
||||
description: "Please enter a valid YouTube video URL",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the new tag
|
||||
const newTag: Tag = {
|
||||
id: Date.now().toString(),
|
||||
text: text,
|
||||
};
|
||||
// Check for duplicates
|
||||
if (videoTags.some((tag) => tag.text === text)) {
|
||||
toast("Duplicate URL", {
|
||||
description: "This YouTube video has already been added",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setVideoTags([...videoTags, newTag]);
|
||||
};
|
||||
// Add the new tag
|
||||
const newTag: Tag = {
|
||||
id: Date.now().toString(),
|
||||
text: text,
|
||||
};
|
||||
|
||||
// Animation variants
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24
|
||||
}
|
||||
}
|
||||
};
|
||||
setVideoTags([...videoTags, newTag]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<motion.div variants={itemVariants}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Youtube className="h-5 w-5" />
|
||||
Add YouTube Videos
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter YouTube video URLs to add to your document collection
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={itemVariants}>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="video-input">Enter YouTube Video URLs</Label>
|
||||
<TagInput
|
||||
id="video-input"
|
||||
tags={videoTags}
|
||||
setTags={setVideoTags}
|
||||
placeholder="Enter a YouTube URL and press Enter"
|
||||
onAddTag={handleAddTag}
|
||||
styleClasses={{
|
||||
inlineTagsContainer:
|
||||
"border-input rounded-lg bg-background shadow-sm shadow-black/5 transition-shadow focus-within:border-ring focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 p-1 gap-1",
|
||||
input: "w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7",
|
||||
tag: {
|
||||
body: "h-7 relative bg-background border border-input hover:bg-background rounded-md font-medium text-xs ps-2 pe-7 flex",
|
||||
closeButton:
|
||||
"absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-foreground",
|
||||
},
|
||||
}}
|
||||
activeTagIndex={activeTagIndex}
|
||||
setActiveTagIndex={setActiveTagIndex}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Add multiple YouTube URLs by pressing Enter after each one
|
||||
</p>
|
||||
</div>
|
||||
// Animation variants
|
||||
const containerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
{error && (
|
||||
<motion.div
|
||||
className="text-sm text-red-500 mt-2"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
const itemVariants: Variants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="bg-muted/50 rounded-lg p-4 text-sm"
|
||||
>
|
||||
<h4 className="font-medium mb-2">Tips for adding YouTube videos:</h4>
|
||||
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
|
||||
<li>Use standard YouTube URLs (youtube.com/watch?v= or youtu.be/)</li>
|
||||
<li>Make sure videos are publicly accessible</li>
|
||||
<li>Supported formats: youtube.com/watch?v=VIDEO_ID or youtu.be/VIDEO_ID</li>
|
||||
<li>Processing may take some time depending on video length</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<motion.div initial="hidden" animate="visible" variants={containerVariants}>
|
||||
<Card className="max-w-2xl mx-auto">
|
||||
<motion.div variants={itemVariants}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<IconBrandYoutube className="h-5 w-5" />
|
||||
Add YouTube Videos
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter YouTube video URLs to add to your document collection
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</motion.div>
|
||||
|
||||
{videoTags.length > 0 && (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="mt-4 space-y-2"
|
||||
>
|
||||
<h4 className="font-medium">Preview:</h4>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{videoTags.map((tag, index) => {
|
||||
const videoId = extractVideoId(tag.text);
|
||||
return videoId ? (
|
||||
<motion.div
|
||||
key={tag.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="relative aspect-video rounded-lg overflow-hidden border"
|
||||
>
|
||||
<iframe
|
||||
width="100%"
|
||||
height="100%"
|
||||
src={`https://www.youtube.com/embed/${videoId}`}
|
||||
title="YouTube video player"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
</motion.div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={itemVariants}>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || videoTags.length === 0}
|
||||
className="relative overflow-hidden"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<motion.span
|
||||
initial={{ x: -5, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="mr-2"
|
||||
>
|
||||
<Youtube className="h-4 w-4" />
|
||||
</motion.span>
|
||||
Submit YouTube Videos
|
||||
</>
|
||||
)}
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-primary/10"
|
||||
initial={{ x: "-100%" }}
|
||||
animate={isSubmitting ? { x: "0%" } : { x: "-100%" }}
|
||||
transition={{ duration: 0.5, ease: "easeInOut" }}
|
||||
/>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</motion.div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
<motion.div variants={itemVariants}>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="video-input">Enter YouTube Video URLs</Label>
|
||||
<TagInput
|
||||
id="video-input"
|
||||
tags={videoTags}
|
||||
setTags={setVideoTags}
|
||||
placeholder="Enter a YouTube URL and press Enter"
|
||||
onAddTag={handleAddTag}
|
||||
styleClasses={{
|
||||
inlineTagsContainer:
|
||||
"border-input rounded-lg bg-background shadow-sm shadow-black/5 transition-shadow focus-within:border-ring focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 p-1 gap-1",
|
||||
input: "w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7",
|
||||
tag: {
|
||||
body: "h-7 relative bg-background border border-input hover:bg-background rounded-md font-medium text-xs ps-2 pe-7 flex",
|
||||
closeButton:
|
||||
"absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-foreground",
|
||||
},
|
||||
}}
|
||||
activeTagIndex={activeTagIndex}
|
||||
setActiveTagIndex={setActiveTagIndex}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Add multiple YouTube URLs by pressing Enter after each one
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<motion.div
|
||||
className="text-sm text-red-500 mt-2"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div variants={itemVariants} className="bg-muted/50 rounded-lg p-4 text-sm">
|
||||
<h4 className="font-medium mb-2">Tips for adding YouTube videos:</h4>
|
||||
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
|
||||
<li>Use standard YouTube URLs (youtube.com/watch?v= or youtu.be/)</li>
|
||||
<li>Make sure videos are publicly accessible</li>
|
||||
<li>Supported formats: youtube.com/watch?v=VIDEO_ID or youtu.be/VIDEO_ID</li>
|
||||
<li>Processing may take some time depending on video length</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
|
||||
{videoTags.length > 0 && (
|
||||
<motion.div variants={itemVariants} className="mt-4 space-y-2">
|
||||
<h4 className="font-medium">Preview:</h4>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{videoTags.map((tag, index) => {
|
||||
const videoId = extractVideoId(tag.text);
|
||||
return videoId ? (
|
||||
<motion.div
|
||||
key={tag.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="relative aspect-video rounded-lg overflow-hidden border"
|
||||
>
|
||||
<iframe
|
||||
width="100%"
|
||||
height="100%"
|
||||
src={`https://www.youtube.com/embed/${videoId}`}
|
||||
title="YouTube video player"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
</motion.div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={itemVariants}>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || videoTags.length === 0}
|
||||
className="relative overflow-hidden"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<motion.span
|
||||
initial={{ x: -5, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="mr-2"
|
||||
>
|
||||
<IconBrandYoutube className="h-4 w-4" />
|
||||
</motion.span>
|
||||
Submit YouTube Videos
|
||||
</>
|
||||
)}
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-primary/10"
|
||||
initial={{ x: "-100%" }}
|
||||
animate={isSubmitting ? { x: "0%" } : { x: "-100%" }}
|
||||
transition={{ duration: 0.5, ease: "easeInOut" }}
|
||||
/>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</motion.div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,100 +1,99 @@
|
|||
// Server component
|
||||
import React, { use } from 'react'
|
||||
import { DashboardClientLayout } from './client-layout'
|
||||
import type React from "react";
|
||||
import { use } from "react";
|
||||
import { DashboardClientLayout } from "./client-layout";
|
||||
|
||||
export default function DashboardLayout({
|
||||
params,
|
||||
children
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string }>,
|
||||
children: React.ReactNode
|
||||
export default function DashboardLayout({
|
||||
params,
|
||||
children,
|
||||
}: {
|
||||
params: Promise<{ search_space_id: string }>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Use React.use to unwrap the params Promise
|
||||
const { search_space_id } = use(params);
|
||||
// Use React.use to unwrap the params Promise
|
||||
const { search_space_id } = use(params);
|
||||
|
||||
const customNavSecondary = [
|
||||
{
|
||||
title: `All Search Spaces`,
|
||||
url: `#`,
|
||||
icon: "Info",
|
||||
},
|
||||
{
|
||||
title: `All Search Spaces`,
|
||||
url: "/dashboard",
|
||||
icon: "Undo2",
|
||||
},
|
||||
]
|
||||
const customNavSecondary = [
|
||||
{
|
||||
title: `All Search Spaces`,
|
||||
url: `#`,
|
||||
icon: "Info",
|
||||
},
|
||||
{
|
||||
title: `All Search Spaces`,
|
||||
url: "/dashboard",
|
||||
icon: "Undo2",
|
||||
},
|
||||
];
|
||||
|
||||
const customNavMain = [
|
||||
{
|
||||
title: "Researcher",
|
||||
url: `/dashboard/${search_space_id}/researcher`,
|
||||
icon: "SquareTerminal",
|
||||
isActive: true,
|
||||
items: [],
|
||||
},
|
||||
const customNavMain = [
|
||||
{
|
||||
title: "Researcher",
|
||||
url: `/dashboard/${search_space_id}/researcher`,
|
||||
icon: "SquareTerminal",
|
||||
isActive: true,
|
||||
items: [],
|
||||
},
|
||||
|
||||
{
|
||||
title: "Documents",
|
||||
url: "#",
|
||||
icon: "FileStack",
|
||||
items: [
|
||||
{
|
||||
title: "Upload Documents",
|
||||
url: `/dashboard/${search_space_id}/documents/upload`,
|
||||
},
|
||||
// { TODO: FIX THIS AND ADD IT BACK
|
||||
// title: "Add Webpages",
|
||||
// url: `/dashboard/${search_space_id}/documents/webpage`,
|
||||
// },
|
||||
{
|
||||
title: "Add Youtube Videos",
|
||||
url: `/dashboard/${search_space_id}/documents/youtube`,
|
||||
},
|
||||
{
|
||||
title: "Manage Documents",
|
||||
url: `/dashboard/${search_space_id}/documents`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Connectors",
|
||||
url: `#`,
|
||||
icon: "Cable",
|
||||
items: [
|
||||
{
|
||||
title: "Add Connector",
|
||||
url: `/dashboard/${search_space_id}/connectors/add`,
|
||||
},
|
||||
{
|
||||
title: "Manage Connectors",
|
||||
url: `/dashboard/${search_space_id}/connectors`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Podcasts",
|
||||
url: `/dashboard/${search_space_id}/podcasts`,
|
||||
icon: "Podcast",
|
||||
items: [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Logs",
|
||||
url: `/dashboard/${search_space_id}/logs`,
|
||||
icon: "FileText",
|
||||
items: [
|
||||
],
|
||||
}
|
||||
]
|
||||
{
|
||||
title: "Documents",
|
||||
url: "#",
|
||||
icon: "FileStack",
|
||||
items: [
|
||||
{
|
||||
title: "Upload Documents",
|
||||
url: `/dashboard/${search_space_id}/documents/upload`,
|
||||
},
|
||||
// { TODO: FIX THIS AND ADD IT BACK
|
||||
// title: "Add Webpages",
|
||||
// url: `/dashboard/${search_space_id}/documents/webpage`,
|
||||
// },
|
||||
{
|
||||
title: "Add Youtube Videos",
|
||||
url: `/dashboard/${search_space_id}/documents/youtube`,
|
||||
},
|
||||
{
|
||||
title: "Manage Documents",
|
||||
url: `/dashboard/${search_space_id}/documents`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Connectors",
|
||||
url: `#`,
|
||||
icon: "Cable",
|
||||
items: [
|
||||
{
|
||||
title: "Add Connector",
|
||||
url: `/dashboard/${search_space_id}/connectors/add`,
|
||||
},
|
||||
{
|
||||
title: "Manage Connectors",
|
||||
url: `/dashboard/${search_space_id}/connectors`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Podcasts",
|
||||
url: `/dashboard/${search_space_id}/podcasts`,
|
||||
icon: "Podcast",
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: "Logs",
|
||||
url: `/dashboard/${search_space_id}/logs`,
|
||||
icon: "FileText",
|
||||
items: [],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DashboardClientLayout
|
||||
searchSpaceId={search_space_id}
|
||||
navSecondary={customNavSecondary}
|
||||
navMain={customNavMain}
|
||||
>
|
||||
{children}
|
||||
</DashboardClientLayout>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<DashboardClientLayout
|
||||
searchSpaceId={search_space_id}
|
||||
navSecondary={customNavSecondary}
|
||||
navMain={customNavMain}
|
||||
>
|
||||
{children}
|
||||
</DashboardClientLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,20 +1,24 @@
|
|||
import { Suspense } from 'react';
|
||||
import PodcastsPageClient from './podcasts-client';
|
||||
import { Suspense } from "react";
|
||||
import PodcastsPageClient from "./podcasts-client";
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
search_space_id: string;
|
||||
};
|
||||
params: {
|
||||
search_space_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PodcastsPage({ params }: PageProps) {
|
||||
const { search_space_id: searchSpaceId } = await Promise.resolve(params);
|
||||
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center h-[60vh]">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
</div>}>
|
||||
<PodcastsPageClient searchSpaceId={searchSpaceId} />
|
||||
</Suspense>
|
||||
);
|
||||
const { search_space_id: searchSpaceId } = await Promise.resolve(params);
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-[60vh]">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PodcastsPageClient searchSpaceId={searchSpaceId} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion, type Variants } from "framer-motion";
|
||||
import {
|
||||
Calendar,
|
||||
MoreHorizontal,
|
||||
|
|
@ -16,8 +16,9 @@ import {
|
|||
VolumeX,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { toast } from "sonner";
|
||||
// UI Components
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
|
@ -45,7 +46,6 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface PodcastItem {
|
||||
id: number;
|
||||
|
|
@ -60,7 +60,7 @@ interface PodcastsPageClientProps {
|
|||
searchSpaceId: string;
|
||||
}
|
||||
|
||||
const pageVariants = {
|
||||
const pageVariants: Variants = {
|
||||
initial: { opacity: 0 },
|
||||
enter: {
|
||||
opacity: 1,
|
||||
|
|
@ -69,7 +69,7 @@ const pageVariants = {
|
|||
exit: { opacity: 0, transition: { duration: 0.3, ease: "easeInOut" } },
|
||||
};
|
||||
|
||||
const podcastCardVariants = {
|
||||
const podcastCardVariants: Variants = {
|
||||
initial: { scale: 0.95, y: 20, opacity: 0 },
|
||||
animate: {
|
||||
scale: 1,
|
||||
|
|
@ -83,9 +83,7 @@ const podcastCardVariants = {
|
|||
|
||||
const MotionCard = motion(Card);
|
||||
|
||||
export default function PodcastsPageClient({
|
||||
searchSpaceId,
|
||||
}: PodcastsPageClientProps) {
|
||||
export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClientProps) {
|
||||
const [podcasts, setPodcasts] = useState<PodcastItem[]>([]);
|
||||
const [filteredPodcasts, setFilteredPodcasts] = useState<PodcastItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
|
@ -100,9 +98,7 @@ export default function PodcastsPageClient({
|
|||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Audio player state
|
||||
const [currentPodcast, setCurrentPodcast] = useState<PodcastItem | null>(
|
||||
null,
|
||||
);
|
||||
const [currentPodcast, setCurrentPodcast] = useState<PodcastItem | null>(null);
|
||||
const [audioSrc, setAudioSrc] = useState<string | undefined>(undefined);
|
||||
const [isAudioLoading, setIsAudioLoading] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
|
@ -141,13 +137,13 @@ export default function PodcastsPageClient({
|
|||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
`Failed to fetch podcasts: ${response.status} ${errorData?.detail || ""}`,
|
||||
`Failed to fetch podcasts: ${response.status} ${errorData?.detail || ""}`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -157,9 +153,7 @@ export default function PodcastsPageClient({
|
|||
setError(null);
|
||||
} catch (error) {
|
||||
console.error("Error fetching podcasts:", error);
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
);
|
||||
setError(error instanceof Error ? error.message : "Unknown error occurred");
|
||||
setPodcasts([]);
|
||||
setFilteredPodcasts([]);
|
||||
} finally {
|
||||
|
|
@ -168,7 +162,7 @@ export default function PodcastsPageClient({
|
|||
};
|
||||
|
||||
fetchPodcasts();
|
||||
}, [searchSpaceId]);
|
||||
}, []);
|
||||
|
||||
// Filter and sort podcasts based on search query and sort order
|
||||
useEffect(() => {
|
||||
|
|
@ -177,15 +171,11 @@ export default function PodcastsPageClient({
|
|||
// Filter by search term
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter((podcast) =>
|
||||
podcast.title.toLowerCase().includes(query),
|
||||
);
|
||||
result = result.filter((podcast) => podcast.title.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
// Filter by search space
|
||||
result = result.filter(
|
||||
(podcast) => podcast.search_space_id === parseInt(searchSpaceId),
|
||||
);
|
||||
result = result.filter((podcast) => podcast.search_space_id === parseInt(searchSpaceId));
|
||||
|
||||
// Sort podcasts
|
||||
result.sort((a, b) => {
|
||||
|
|
@ -294,7 +284,7 @@ export default function PodcastsPageClient({
|
|||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = Math.min(
|
||||
audioRef.current.duration,
|
||||
audioRef.current.currentTime + 10,
|
||||
audioRef.current.currentTime + 10
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -302,10 +292,7 @@ export default function PodcastsPageClient({
|
|||
// Skip backward 10 seconds
|
||||
const skipBackward = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = Math.max(
|
||||
0,
|
||||
audioRef.current.currentTime - 10,
|
||||
);
|
||||
audioRef.current.currentTime = Math.max(0, audioRef.current.currentTime - 10);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -361,13 +348,11 @@ export default function PodcastsPageClient({
|
|||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch audio stream: ${response.statusText}`,
|
||||
);
|
||||
throw new Error(`Failed to fetch audio stream: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
|
|
@ -389,11 +374,7 @@ export default function PodcastsPageClient({
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching or playing podcast:", error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to load podcast audio.",
|
||||
);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to load podcast audio.");
|
||||
// Reset state on error
|
||||
setCurrentPodcast(null);
|
||||
setAudioSrc(undefined);
|
||||
|
|
@ -422,7 +403,7 @@ export default function PodcastsPageClient({
|
|||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -435,7 +416,7 @@ export default function PodcastsPageClient({
|
|||
|
||||
// Update local state by removing the deleted podcast
|
||||
setPodcasts((prevPodcasts) =>
|
||||
prevPodcasts.filter((podcast) => podcast.id !== podcastToDelete.id),
|
||||
prevPodcasts.filter((podcast) => podcast.id !== podcastToDelete.id)
|
||||
);
|
||||
|
||||
// If the current playing podcast is deleted, stop playback
|
||||
|
|
@ -450,9 +431,7 @@ export default function PodcastsPageClient({
|
|||
toast.success("Podcast deleted successfully");
|
||||
} catch (error) {
|
||||
console.error("Error deleting podcast:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to delete podcast",
|
||||
);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to delete podcast");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
|
|
@ -507,9 +486,7 @@ export default function PodcastsPageClient({
|
|||
<div className="flex items-center justify-center h-40">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading podcasts...
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Loading podcasts...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -563,11 +540,13 @@ export default function PodcastsPageClient({
|
|||
>
|
||||
<div className="relative w-full aspect-[16/10] mb-4 rounded-lg overflow-hidden">
|
||||
{/* Podcast image with gradient overlay */}
|
||||
<img
|
||||
<Image
|
||||
src={PODCAST_IMAGE_URL}
|
||||
alt="Podcast illustration"
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105 brightness-[0.85] contrast-[1.1]"
|
||||
loading="lazy"
|
||||
width={100}
|
||||
height={100}
|
||||
/>
|
||||
|
||||
{/* Better overlay with gradient for improved text legibility */}
|
||||
|
|
@ -589,18 +568,13 @@ export default function PodcastsPageClient({
|
|||
transition={{ type: "spring", damping: 20 }}
|
||||
>
|
||||
<div className="h-14 w-14 rounded-full border-4 border-primary/30 border-t-primary animate-spin"></div>
|
||||
<p className="text-sm text-foreground font-medium">
|
||||
Loading podcast...
|
||||
</p>
|
||||
<p className="text-sm text-foreground font-medium">Loading podcast...</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Play button with animations */}
|
||||
{!(
|
||||
currentPodcast?.id === podcast.id &&
|
||||
(isPlaying || isAudioLoading)
|
||||
) && (
|
||||
{!(currentPodcast?.id === podcast.id && (isPlaying || isAudioLoading)) && (
|
||||
<motion.div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
|
|
@ -636,42 +610,40 @@ export default function PodcastsPageClient({
|
|||
)}
|
||||
|
||||
{/* Pause button with animations */}
|
||||
{currentPodcast?.id === podcast.id &&
|
||||
isPlaying &&
|
||||
!isAudioLoading && (
|
||||
<motion.div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-16 w-16 rounded-full
|
||||
{currentPodcast?.id === podcast.id && isPlaying && !isAudioLoading && (
|
||||
<motion.div
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-16 w-16 rounded-full
|
||||
bg-background/80 hover:bg-background/95 backdrop-blur-md
|
||||
transition-all duration-200 shadow-xl border-0
|
||||
flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePlayPause();
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePlayPause();
|
||||
}}
|
||||
disabled={isAudioLoading}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 10,
|
||||
}}
|
||||
disabled={isAudioLoading}
|
||||
className="text-primary w-10 h-10 flex items-center justify-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 10,
|
||||
}}
|
||||
className="text-primary w-10 h-10 flex items-center justify-center"
|
||||
>
|
||||
<Pause className="h-8 w-8" />
|
||||
</motion.div>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
<Pause className="h-8 w-8" />
|
||||
</motion.div>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Now playing indicator */}
|
||||
{currentPodcast?.id === podcast.id && !isAudioLoading && (
|
||||
|
|
@ -705,7 +677,8 @@ export default function PodcastsPageClient({
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<div
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-1.5 bg-muted rounded-full cursor-pointer group relative overflow-hidden"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -713,10 +686,7 @@ export default function PodcastsPageClient({
|
|||
const container = e.currentTarget;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const percentage = Math.max(
|
||||
0,
|
||||
Math.min(1, x / rect.width),
|
||||
);
|
||||
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
||||
const newTime = percentage * duration;
|
||||
handleSeek([newTime]);
|
||||
}}
|
||||
|
|
@ -735,7 +705,7 @@ export default function PodcastsPageClient({
|
|||
whileHover={{ scale: 1.5 }}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Button>
|
||||
<div className="flex justify-between mt-1.5 text-xs text-muted-foreground">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
|
|
@ -750,10 +720,7 @@ export default function PodcastsPageClient({
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.2 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -768,10 +735,7 @@ export default function PodcastsPageClient({
|
|||
<SkipBack className="w-5 h-5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.2 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -789,10 +753,7 @@ export default function PodcastsPageClient({
|
|||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.2 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -872,9 +833,7 @@ export default function PodcastsPageClient({
|
|||
</div>
|
||||
|
||||
<div className="flex-grow min-w-0">
|
||||
<h4 className="font-medium text-sm line-clamp-1">
|
||||
{currentPodcast.title}
|
||||
</h4>
|
||||
<h4 className="font-medium text-sm line-clamp-1">{currentPodcast.title}</h4>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="flex-grow relative">
|
||||
|
|
@ -901,24 +860,13 @@ export default function PodcastsPageClient({
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={skipBackward}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button variant="ghost" size="icon" onClick={skipBackward} className="h-8 w-8">
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
|
|
@ -933,25 +881,14 @@ export default function PodcastsPageClient({
|
|||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={skipForward}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button variant="ghost" size="icon" onClick={skipForward} className="h-8 w-8">
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
<div className="hidden md:flex items-center gap-2 ml-4 w-32">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -984,10 +921,7 @@ export default function PodcastsPageClient({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
|
|
@ -1014,8 +948,8 @@ export default function PodcastsPageClient({
|
|||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-medium">{podcastToDelete?.title}</span>?
|
||||
This action cannot be undone.
|
||||
<span className="font-medium">{podcastToDelete?.title}</span>? This action cannot be
|
||||
undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
|
|
@ -1086,17 +1020,16 @@ export default function PodcastsPageClient({
|
|||
console.error("Audio error code:", audioRef.current.error.code);
|
||||
|
||||
// Don't show error message for aborted loads
|
||||
if (
|
||||
audioRef.current.error.code !==
|
||||
audioRef.current.error.MEDIA_ERR_ABORTED
|
||||
) {
|
||||
if (audioRef.current.error.code !== audioRef.current.error.MEDIA_ERR_ABORTED) {
|
||||
toast.error("Error playing audio. Please try again.");
|
||||
}
|
||||
}
|
||||
// Reset playing state on error
|
||||
setIsPlaying(false);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,19 +58,12 @@ export default function ResearcherPage() {
|
|||
const getChatStateStorageKey = (searchSpaceId: string, chatId: string) =>
|
||||
`surfsense_chat_state_${searchSpaceId}_${chatId}`;
|
||||
|
||||
const storeChatState = (
|
||||
searchSpaceId: string,
|
||||
chatId: string,
|
||||
state: ChatState,
|
||||
) => {
|
||||
const storeChatState = (searchSpaceId: string, chatId: string, state: ChatState) => {
|
||||
const key = getChatStateStorageKey(searchSpaceId, chatId);
|
||||
localStorage.setItem(key, JSON.stringify(state));
|
||||
};
|
||||
|
||||
const restoreChatState = (
|
||||
searchSpaceId: string,
|
||||
chatId: string,
|
||||
): ChatState | null => {
|
||||
const restoreChatState = (searchSpaceId: string, chatId: string): ChatState | null => {
|
||||
const key = getChatStateStorageKey(searchSpaceId, chatId);
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
|
|
@ -108,13 +101,9 @@ export default function ResearcherPage() {
|
|||
|
||||
const customHandlerAppend = async (
|
||||
message: Message | CreateMessage,
|
||||
chatRequestOptions?: { data?: any },
|
||||
chatRequestOptions?: { data?: any }
|
||||
) => {
|
||||
const newChatId = await createChat(
|
||||
message.content,
|
||||
researchMode,
|
||||
selectedConnectors,
|
||||
);
|
||||
const newChatId = await createChat(message.content, researchMode, selectedConnectors);
|
||||
if (newChatId) {
|
||||
// Store chat state before navigation
|
||||
storeChatState(search_space_id as string, newChatId, {
|
||||
|
|
@ -138,10 +127,7 @@ export default function ResearcherPage() {
|
|||
// Restore chat state from localStorage on page load
|
||||
useEffect(() => {
|
||||
if (chatIdParam && search_space_id) {
|
||||
const restoredState = restoreChatState(
|
||||
search_space_id as string,
|
||||
chatIdParam,
|
||||
);
|
||||
const restoredState = restoreChatState(search_space_id as string, chatIdParam);
|
||||
if (restoredState) {
|
||||
setSelectedDocuments(restoredState.selectedDocuments);
|
||||
setSelectedConnectors(restoredState.selectedConnectors);
|
||||
|
|
@ -168,19 +154,13 @@ export default function ResearcherPage() {
|
|||
setResearchMode(chatData.type as ResearchMode);
|
||||
}
|
||||
|
||||
if (
|
||||
chatData.initial_connectors &&
|
||||
Array.isArray(chatData.initial_connectors)
|
||||
) {
|
||||
if (chatData.initial_connectors && Array.isArray(chatData.initial_connectors)) {
|
||||
setSelectedConnectors(chatData.initial_connectors);
|
||||
}
|
||||
|
||||
// Load existing messages
|
||||
if (chatData.messages && Array.isArray(chatData.messages)) {
|
||||
if (
|
||||
chatData.messages.length === 1 &&
|
||||
chatData.messages[0].role === "user"
|
||||
) {
|
||||
if (chatData.messages.length === 1 && chatData.messages[0].role === "user") {
|
||||
// Single user message - append to trigger LLM response
|
||||
handler.append({
|
||||
role: "user",
|
||||
|
|
@ -205,12 +185,7 @@ export default function ResearcherPage() {
|
|||
handler.messages.length > 0 &&
|
||||
handler.messages[handler.messages.length - 1]?.role === "assistant"
|
||||
) {
|
||||
updateChat(
|
||||
chatIdParam,
|
||||
handler.messages,
|
||||
researchMode,
|
||||
selectedConnectors,
|
||||
);
|
||||
updateChat(chatIdParam, handler.messages, researchMode, selectedConnectors);
|
||||
}
|
||||
}, [handler.messages, handler.status, chatIdParam, isNewChat]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,204 +1,185 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useApiKey } from "@/hooks/use-api-key";
|
||||
|
||||
const fadeIn = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { duration: 0.4 } },
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { duration: 0.4 } },
|
||||
};
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ApiKeyClient = () => {
|
||||
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="flex justify-center w-full min-h-screen py-10 px-4">
|
||||
<motion.div
|
||||
className="w-full max-w-3xl"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
<motion.div className="mb-8 text-center" variants={fadeIn}>
|
||||
<h1 className="text-3xl font-bold tracking-tight">API Key</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Your API key for authenticating with the SurfSense API.
|
||||
</p>
|
||||
</motion.div>
|
||||
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="flex justify-center w-full min-h-screen py-10 px-4">
|
||||
<motion.div
|
||||
className="w-full max-w-3xl"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
<motion.div className="mb-8 text-center" variants={fadeIn}>
|
||||
<h1 className="text-3xl font-bold tracking-tight">API Key</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Your API key for authenticating with the SurfSense API.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeIn}>
|
||||
<Alert className="mb-8">
|
||||
<IconKey className="h-4 w-4" />
|
||||
<AlertTitle>Important</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your API key grants full access to your account. Never share it
|
||||
publicly or with unauthorized users.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
<motion.div variants={fadeIn}>
|
||||
<Alert className="mb-8">
|
||||
<IconKey className="h-4 w-4" />
|
||||
<AlertTitle>Important</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your API key grants full access to your account. Never share it publicly or with
|
||||
unauthorized users.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeIn}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Your API Key</CardTitle>
|
||||
<CardDescription>
|
||||
Use this key to authenticate your API requests.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AnimatePresence mode="wait">
|
||||
{isLoading ? (
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="h-10 w-full bg-muted animate-pulse rounded-md"
|
||||
/>
|
||||
) : apiKey ? (
|
||||
<motion.div
|
||||
key="api-key"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<div className="bg-muted p-3 rounded-md flex-1 font-mono text-sm overflow-x-auto whitespace-nowrap">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{apiKey}
|
||||
</motion.div>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={copyToClipboard}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<motion.div
|
||||
whileTap={{ scale: 0.9 }}
|
||||
animate={copied ? { scale: [1, 1.2, 1] } : {}}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck className="h-4 w-4" />
|
||||
) : (
|
||||
<IconCopy className="h-4 w-4" />
|
||||
)}
|
||||
</motion.div>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{copied ? "Copied!" : "Copy to clipboard"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="no-key"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="text-muted-foreground text-center"
|
||||
>
|
||||
No API key found.
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div variants={fadeIn}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Your API Key</CardTitle>
|
||||
<CardDescription>Use this key to authenticate your API requests.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AnimatePresence mode="wait">
|
||||
{isLoading ? (
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="h-10 w-full bg-muted animate-pulse rounded-md"
|
||||
/>
|
||||
) : apiKey ? (
|
||||
<motion.div
|
||||
key="api-key"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<div className="bg-muted p-3 rounded-md flex-1 font-mono text-sm overflow-x-auto whitespace-nowrap">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{apiKey}
|
||||
</motion.div>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={copyToClipboard}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<motion.div
|
||||
whileTap={{ scale: 0.9 }}
|
||||
animate={copied ? { scale: [1, 1.2, 1] } : {}}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck className="h-4 w-4" />
|
||||
) : (
|
||||
<IconCopy className="h-4 w-4" />
|
||||
)}
|
||||
</motion.div>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{copied ? "Copied!" : "Copy to clipboard"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="no-key"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="text-muted-foreground text-center"
|
||||
>
|
||||
No API key found.
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="mt-8"
|
||||
variants={fadeIn}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-4 text-center">
|
||||
How to use your API key
|
||||
</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
<motion.div variants={fadeIn}>
|
||||
<h3 className="font-medium mb-2 text-center">
|
||||
Authentication
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Include your API key in the Authorization header of your
|
||||
requests:
|
||||
</p>
|
||||
<motion.pre
|
||||
className="bg-muted p-3 rounded-md mt-2 overflow-x-auto"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 10 }}
|
||||
>
|
||||
<code className="text-xs">
|
||||
Authorization: Bearer {apiKey || "YOUR_API_KEY"}
|
||||
</code>
|
||||
</motion.pre>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => router.push("/dashboard")}
|
||||
className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 hover:bg-primary/30 transition-colors"
|
||||
aria-label="Back to Dashboard"
|
||||
type="button"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<motion.div
|
||||
className="mt-8"
|
||||
variants={fadeIn}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-4 text-center">How to use your API key</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
<motion.div variants={fadeIn}>
|
||||
<h3 className="font-medium mb-2 text-center">Authentication</h3>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Include your API key in the Authorization header of your requests:
|
||||
</p>
|
||||
<motion.pre
|
||||
className="bg-muted p-3 rounded-md mt-2 overflow-x-auto"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 10 }}
|
||||
>
|
||||
<code className="text-xs">
|
||||
Authorization: Bearer {apiKey || "YOUR_API_KEY"}
|
||||
</code>
|
||||
</motion.pre>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => router.push("/dashboard")}
|
||||
className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 hover:bg-primary/30 transition-colors"
|
||||
aria-label="Back to Dashboard"
|
||||
type="button"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeyClient;
|
||||
|
|
|
|||
|
|
@ -1,32 +1,32 @@
|
|||
'use client'
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Loading component with animation
|
||||
const LoadingComponent = () => (
|
||||
<div className="flex flex-col justify-center items-center min-h-screen">
|
||||
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mb-4"></div>
|
||||
<p className="text-muted-foreground">Loading API Key Management...</p>
|
||||
</div>
|
||||
)
|
||||
<div className="flex flex-col justify-center items-center min-h-screen">
|
||||
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mb-4"></div>
|
||||
<p className="text-muted-foreground">Loading API Key Management...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Dynamically import the ApiKeyClient component
|
||||
const ApiKeyClient = dynamic(() => import('./api-key-client'), {
|
||||
ssr: false,
|
||||
loading: () => <LoadingComponent />
|
||||
})
|
||||
const ApiKeyClient = dynamic(() => import("./api-key-client"), {
|
||||
ssr: false,
|
||||
loading: () => <LoadingComponent />,
|
||||
});
|
||||
|
||||
export default function ClientWrapper() {
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!isMounted) {
|
||||
return <LoadingComponent />
|
||||
}
|
||||
|
||||
return <ApiKeyClient />
|
||||
}
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!isMounted) {
|
||||
return <LoadingComponent />;
|
||||
}
|
||||
|
||||
return <ApiKeyClient />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react'
|
||||
import ClientWrapper from './client-wrapper'
|
||||
"use client";
|
||||
|
||||
import ClientWrapper from "./client-wrapper";
|
||||
|
||||
export default function ApiKeyPage() {
|
||||
return <ClientWrapper />
|
||||
}
|
||||
return <ClientWrapper />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,90 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLLMPreferences } from '@/hooks/use-llm-configs';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const router = useRouter();
|
||||
const { loading, error, isOnboardingComplete } = useLLMPreferences();
|
||||
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
||||
const router = useRouter();
|
||||
const { loading, error, isOnboardingComplete } = useLLMPreferences();
|
||||
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is authenticated
|
||||
const token = localStorage.getItem('surfsense_bearer_token');
|
||||
if (!token) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
setIsCheckingAuth(false);
|
||||
}, [router]);
|
||||
useEffect(() => {
|
||||
// Check if user is authenticated
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
if (!token) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
setIsCheckingAuth(false);
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for preferences to load, then check if onboarding is complete
|
||||
if (!loading && !error && !isCheckingAuth) {
|
||||
if (!isOnboardingComplete()) {
|
||||
router.push('/onboard');
|
||||
}
|
||||
}
|
||||
}, [loading, error, isCheckingAuth, isOnboardingComplete, router]);
|
||||
useEffect(() => {
|
||||
// Wait for preferences to load, then check if onboarding is complete
|
||||
if (!loading && !error && !isCheckingAuth) {
|
||||
if (!isOnboardingComplete()) {
|
||||
router.push("/onboard");
|
||||
}
|
||||
}
|
||||
}, [loading, error, isCheckingAuth, isOnboardingComplete, router]);
|
||||
|
||||
// Show loading screen while checking authentication or loading preferences
|
||||
if (isCheckingAuth || loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl font-medium">Loading Dashboard</CardTitle>
|
||||
<CardDescription>Checking your configuration...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center py-6">
|
||||
<Loader2 className="h-12 w-12 text-primary animate-spin" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Show loading screen while checking authentication or loading preferences
|
||||
if (isCheckingAuth || loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl font-medium">Loading Dashboard</CardTitle>
|
||||
<CardDescription>Checking your configuration...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center py-6">
|
||||
<Loader2 className="h-12 w-12 text-primary animate-spin" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error screen if there's an error loading preferences
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
||||
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl font-medium text-destructive">Configuration Error</CardTitle>
|
||||
<CardDescription>Failed to load your LLM configuration</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Show error screen if there's an error loading preferences
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
||||
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl font-medium text-destructive">
|
||||
Configuration Error
|
||||
</CardTitle>
|
||||
<CardDescription>Failed to load your LLM configuration</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Only render children if onboarding is complete
|
||||
if (isOnboardingComplete()) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
// Only render children if onboarding is complete
|
||||
if (isOnboardingComplete()) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// This should not be reached due to redirect, but just in case
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl font-medium">Redirecting...</CardTitle>
|
||||
<CardDescription>Taking you to complete your setup</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center py-6">
|
||||
<Loader2 className="h-12 w-12 text-primary animate-spin" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// This should not be reached due to redirect, but just in case
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl font-medium">Redirecting...</CardTitle>
|
||||
<CardDescription>Taking you to complete your setup</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center py-6">
|
||||
<Loader2 className="h-12 w-12 text-primary animate-spin" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, Search, Trash2, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { Tilt } from '@/components/ui/tilt'
|
||||
import { Spotlight } from '@/components/ui/spotlight'
|
||||
import { Logo } from '@/components/Logo';
|
||||
import { ThemeTogglerComponent } from '@/components/theme/theme-toggle';
|
||||
import { UserDropdown } from '@/components/UserDropdown';
|
||||
import { toast } from 'sonner';
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { AlertCircle, Loader2, Plus, Search, Trash2 } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
|
||||
import { UserDropdown } from "@/components/UserDropdown";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useSearchSpaces } from '@/hooks/use-search-spaces';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { useRouter } from 'next/navigation';
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Spotlight } from "@/components/ui/spotlight";
|
||||
import { Tilt } from "@/components/ui/tilt";
|
||||
import { useSearchSpaces } from "@/hooks/use-search-spaces";
|
||||
import { apiClient } from "@/lib/api";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
is_verified: boolean;
|
||||
id: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
is_verified: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -46,356 +50,354 @@ interface User {
|
|||
* @returns Formatted date string (e.g., "Jan 1, 2023")
|
||||
*/
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
return new Date(dateString).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Loading screen component with animation
|
||||
*/
|
||||
const LoadingScreen = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl font-medium">Loading</CardTitle>
|
||||
<CardDescription>Fetching your search spaces...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center py-6">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<Loader2 className="h-12 w-12 text-primary" />
|
||||
</motion.div>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t pt-4 text-sm text-muted-foreground">
|
||||
This may take a moment
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl font-medium">Loading</CardTitle>
|
||||
<CardDescription>Fetching your search spaces...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center py-6">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<Loader2 className="h-12 w-12 text-primary" />
|
||||
</motion.div>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t pt-4 text-sm text-muted-foreground">
|
||||
This may take a moment
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Error screen component with animation
|
||||
*/
|
||||
const ErrorScreen = ({ message }: { message: string }) => {
|
||||
const router = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
<CardTitle className="text-xl font-medium">Error</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Something went wrong</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert variant="destructive" className="bg-destructive/10 border-destructive/30">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error Details</AlertTitle>
|
||||
<AlertDescription className="mt-2">
|
||||
{message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end gap-2 border-t pt-4">
|
||||
<Button variant="outline" onClick={() => router.refresh()}>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button onClick={() => router.push('/')}>
|
||||
Go Home
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
<CardTitle className="text-xl font-medium">Error</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Something went wrong</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert variant="destructive" className="bg-destructive/10 border-destructive/30">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error Details</AlertTitle>
|
||||
<AlertDescription className="mt-2">{message}</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end gap-2 border-t pt-4">
|
||||
<Button variant="outline" onClick={() => router.refresh()}>
|
||||
Try Again
|
||||
</Button>
|
||||
<Button onClick={() => router.push("/")}>Go Home</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DashboardPage = () => {
|
||||
// Animation variants
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
// Animation variants
|
||||
const containerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24,
|
||||
},
|
||||
},
|
||||
};
|
||||
const itemVariants: Variants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const { searchSpaces, loading, error, refreshSearchSpaces } = useSearchSpaces();
|
||||
|
||||
// User state management
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoadingUser, setIsLoadingUser] = useState(true);
|
||||
const [userError, setUserError] = useState<string | null>(null);
|
||||
const { searchSpaces, loading, error, refreshSearchSpaces } = useSearchSpaces();
|
||||
|
||||
// Fetch user details
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
if (typeof window === 'undefined') return;
|
||||
// User state management
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoadingUser, setIsLoadingUser] = useState(true);
|
||||
const [userError, setUserError] = useState<string | null>(null);
|
||||
|
||||
try {
|
||||
const userData = await apiClient.get<User>('users/me');
|
||||
setUser(userData);
|
||||
setUserError(null);
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
setUserError(error instanceof Error ? error.message : 'Unknown error occurred');
|
||||
} finally {
|
||||
setIsLoadingUser(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in fetchUser:', error);
|
||||
setIsLoadingUser(false);
|
||||
}
|
||||
};
|
||||
// Fetch user details
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
fetchUser();
|
||||
}, []);
|
||||
try {
|
||||
const userData = await apiClient.get<User>("users/me");
|
||||
setUser(userData);
|
||||
setUserError(null);
|
||||
} catch (error) {
|
||||
console.error("Error fetching user:", error);
|
||||
setUserError(error instanceof Error ? error.message : "Unknown error occurred");
|
||||
} finally {
|
||||
setIsLoadingUser(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in fetchUser:", error);
|
||||
setIsLoadingUser(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Create user object for UserDropdown
|
||||
const customUser = {
|
||||
name: user?.email ? user.email.split('@')[0] : 'User',
|
||||
email: user?.email || (isLoadingUser ? 'Loading...' : userError ? 'Error loading user' : 'Unknown User'),
|
||||
avatar: '/icon-128.png', // Default avatar
|
||||
};
|
||||
fetchUser();
|
||||
}, []);
|
||||
|
||||
if (loading) return <LoadingScreen />;
|
||||
if (error) return <ErrorScreen message={error} />;
|
||||
// Create user object for UserDropdown
|
||||
const customUser = {
|
||||
name: user?.email ? user.email.split("@")[0] : "User",
|
||||
email:
|
||||
user?.email ||
|
||||
(isLoadingUser ? "Loading..." : userError ? "Error loading user" : "Unknown User"),
|
||||
avatar: "/icon-128.png", // Default avatar
|
||||
};
|
||||
|
||||
const handleDeleteSearchSpace = async (id: number) => {
|
||||
// Send DELETE request to the API
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('surfsense_bearer_token')}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error("Failed to delete search space");
|
||||
throw new Error("Failed to delete search space");
|
||||
}
|
||||
|
||||
// Refresh the search spaces list after successful deletion
|
||||
refreshSearchSpaces();
|
||||
} catch (error) {
|
||||
console.error('Error deleting search space:', error);
|
||||
toast.error("An error occurred while deleting the search space");
|
||||
return;
|
||||
}
|
||||
toast.success("Search space deleted successfully");
|
||||
};
|
||||
if (loading) return <LoadingScreen />;
|
||||
if (error) return <ErrorScreen message={error} />;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="container mx-auto py-10"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<motion.div className="flex flex-col space-y-6" variants={itemVariants}>
|
||||
<div className="flex flex-row space-x-4 justify-between">
|
||||
<div className="flex flex-row space-x-4">
|
||||
<Logo className="w-10 h-10 rounded-md" />
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h1 className="text-4xl font-bold">SurfSense Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Welcome to your SurfSense dashboard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<UserDropdown user={customUser} />
|
||||
<ThemeTogglerComponent />
|
||||
</div>
|
||||
</div>
|
||||
const handleDeleteSearchSpace = async (id: number) => {
|
||||
// Send DELETE request to the API
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
<div className="flex flex-col space-y-6 mt-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-semibold">Your Search Spaces</h2>
|
||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
<Link href="/dashboard/searchspaces">
|
||||
<Button className="h-10">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Search Space
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
if (!response.ok) {
|
||||
toast.error("Failed to delete search space");
|
||||
throw new Error("Failed to delete search space");
|
||||
}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{searchSpaces && searchSpaces.map((space) => (
|
||||
<Link href={`/dashboard/${space.id}/documents`} key={space.id}>
|
||||
<motion.div
|
||||
key={space.id}
|
||||
variants={itemVariants}
|
||||
className="aspect-[4/3]"
|
||||
>
|
||||
// Refresh the search spaces list after successful deletion
|
||||
refreshSearchSpaces();
|
||||
} catch (error) {
|
||||
console.error("Error deleting search space:", error);
|
||||
toast.error("An error occurred while deleting the search space");
|
||||
return;
|
||||
}
|
||||
toast.success("Search space deleted successfully");
|
||||
};
|
||||
|
||||
<Tilt
|
||||
rotationFactor={6}
|
||||
isRevese
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
className="group relative rounded-lg h-full"
|
||||
>
|
||||
<Spotlight
|
||||
className="z-10 from-blue-500/20 via-blue-300/10 to-blue-200/5 blur-2xl"
|
||||
size={248}
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col h-full overflow-hidden rounded-xl border bg-muted/30 backdrop-blur-sm transition-all hover:border-primary/50">
|
||||
<div className="relative h-32 w-full overflow-hidden">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1519389950473-47ba0277781c?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1740&q=80"
|
||||
alt={space.name}
|
||||
className="h-full w-full object-cover grayscale duration-700 group-hover:grayscale-0"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background/80 to-transparent" />
|
||||
<div className="absolute top-2 right-2">
|
||||
<div onClick={(e) => e.preventDefault()}>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full bg-background/50 backdrop-blur-sm hover:bg-destructive/90 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Search Space</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{space.name}"? This action cannot be undone.
|
||||
All documents, chats, and podcasts in this search space will be permanently deleted.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDeleteSearchSpace(space.id)}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col justify-between p-4">
|
||||
<div>
|
||||
<h3 className="font-medium text-lg">{space.name}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{space.description}</p>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between text-xs text-muted-foreground">
|
||||
{/* <span>{space.title}</span> */}
|
||||
<span>Created {formatDate(space.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tilt>
|
||||
return (
|
||||
<motion.div
|
||||
className="container mx-auto py-10"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<motion.div className="flex flex-col space-y-6" variants={itemVariants}>
|
||||
<div className="flex flex-row space-x-4 justify-between">
|
||||
<div className="flex flex-row space-x-4">
|
||||
<Logo className="w-10 h-10 rounded-md" />
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h1 className="text-4xl font-bold">SurfSense Dashboard</h1>
|
||||
<p className="text-muted-foreground">Welcome to your SurfSense dashboard.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<UserDropdown user={customUser} />
|
||||
<ThemeTogglerComponent />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex flex-col space-y-6 mt-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-semibold">Your Search Spaces</h2>
|
||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
<Link href="/dashboard/searchspaces">
|
||||
<Button className="h-10">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Search Space
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{searchSpaces.length === 0 && (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="col-span-full flex flex-col items-center justify-center p-12 text-center"
|
||||
>
|
||||
<div className="rounded-full bg-muted/50 p-4 mb-4">
|
||||
<Search className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">No search spaces found</h3>
|
||||
<p className="text-muted-foreground mb-6">Create your first search space to get started</p>
|
||||
<Link href="/dashboard/searchspaces">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Search Space
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{searchSpaces &&
|
||||
searchSpaces.length > 0 &&
|
||||
searchSpaces.map((space) => (
|
||||
<Link href={`/dashboard/${space.id}/documents`} key={space.id}>
|
||||
<motion.div key={space.id} variants={itemVariants} className="aspect-[4/3]">
|
||||
<Tilt
|
||||
rotationFactor={6}
|
||||
isRevese
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
className="group relative rounded-lg h-full"
|
||||
>
|
||||
<Spotlight
|
||||
className="z-10 from-blue-500/20 via-blue-300/10 to-blue-200/5 blur-2xl"
|
||||
size={248}
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col h-full overflow-hidden rounded-xl border bg-muted/30 backdrop-blur-sm transition-all hover:border-primary/50">
|
||||
<div className="relative h-32 w-full overflow-hidden">
|
||||
<Image
|
||||
src="https://images.unsplash.com/photo-1519389950473-47ba0277781c?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1740&q=80"
|
||||
alt={space.name}
|
||||
className="h-full w-full object-cover grayscale duration-700 group-hover:grayscale-0"
|
||||
width={248}
|
||||
height={248}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background/80 to-transparent" />
|
||||
<div className="absolute top-2 right-2">
|
||||
<div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full bg-background/50 backdrop-blur-sm hover:bg-destructive/90 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Search Space</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{space.name}"? This
|
||||
action cannot be undone. All documents, chats, and podcasts in
|
||||
this search space will be permanently deleted.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDeleteSearchSpace(space.id)}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{searchSpaces.length > 0 && (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="aspect-[4/3]"
|
||||
>
|
||||
<Tilt
|
||||
rotationFactor={6}
|
||||
isRevese
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
className="group relative rounded-lg h-full"
|
||||
>
|
||||
<Link href="/dashboard/searchspaces" className="flex h-full">
|
||||
<div className="flex flex-col items-center justify-center h-full w-full rounded-xl border border-dashed bg-muted/10 hover:border-primary/50 transition-colors">
|
||||
<Plus className="h-10 w-10 mb-3 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Add New Search Space</span>
|
||||
</div>
|
||||
</Link>
|
||||
</Tilt>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
<div className="flex flex-1 flex-col justify-between p-4">
|
||||
<div>
|
||||
<h3 className="font-medium text-lg">{space.name}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{space.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between text-xs text-muted-foreground">
|
||||
{/* <span>{space.title}</span> */}
|
||||
<span>Created {formatDate(space.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tilt>
|
||||
</motion.div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
export default DashboardPage
|
||||
{searchSpaces.length === 0 && (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="col-span-full flex flex-col items-center justify-center p-12 text-center"
|
||||
>
|
||||
<div className="rounded-full bg-muted/50 p-4 mb-4">
|
||||
<Search className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">No search spaces found</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Create your first search space to get started
|
||||
</p>
|
||||
<Link href="/dashboard/searchspaces">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Search Space
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{searchSpaces.length > 0 && (
|
||||
<motion.div variants={itemVariants} className="aspect-[4/3]">
|
||||
<Tilt
|
||||
rotationFactor={6}
|
||||
isRevese
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
className="group relative rounded-lg h-full"
|
||||
>
|
||||
<Link href="/dashboard/searchspaces" className="flex h-full">
|
||||
<div className="flex flex-col items-center justify-center h-full w-full rounded-xl border border-dashed bg-muted/10 hover:border-primary/50 transition-colors">
|
||||
<Plus className="h-10 w-10 mb-3 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Add New Search Space</span>
|
||||
</div>
|
||||
</Link>
|
||||
</Tilt>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
|
|
|
|||
|
|
@ -1,52 +1,55 @@
|
|||
"use client";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { SearchSpaceForm } from "@/components/search-space-form";
|
||||
import { motion } from "framer-motion";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { SearchSpaceForm } from "@/components/search-space-form";
|
||||
export default function SearchSpacesPage() {
|
||||
const router = useRouter();
|
||||
const handleCreateSearchSpace = async (data: { name: string; description: string }) => {
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('surfsense_bearer_token')}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error("Failed to create search space");
|
||||
throw new Error("Failed to create search space");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
toast.success("Search space created successfully", {
|
||||
description: `"${data.name}" has been created.`,
|
||||
});
|
||||
const router = useRouter();
|
||||
const handleCreateSearchSpace = async (data: { name: string; description: string }) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
);
|
||||
|
||||
router.push(`/dashboard`);
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error('Error creating search space:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
if (!response.ok) {
|
||||
toast.error("Failed to create search space");
|
||||
throw new Error("Failed to create search space");
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="container mx-auto py-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<SearchSpaceForm onSubmit={handleCreateSearchSpace} />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
const result = await response.json();
|
||||
|
||||
toast.success("Search space created successfully", {
|
||||
description: `"${data.name}" has been created.`,
|
||||
});
|
||||
|
||||
router.push(`/dashboard`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Error creating search space:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="container mx-auto py-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<SearchSpaceForm onSubmit={handleCreateSearchSpace} />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,46 +1,37 @@
|
|||
import { source } from '@/lib/source';
|
||||
import {
|
||||
DocsBody,
|
||||
DocsDescription,
|
||||
DocsPage,
|
||||
DocsTitle,
|
||||
} from 'fumadocs-ui/page';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getMDXComponents } from '@/mdx-components';
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc} full={page.data.full}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
<DocsBody>
|
||||
<MDX components={getMDXComponents()} />
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/page";
|
||||
import { notFound } from "next/navigation";
|
||||
import { source } from "@/lib/source";
|
||||
import { getMDXComponents } from "@/mdx-components";
|
||||
|
||||
export default async function Page(props: { params: Promise<{ slug?: string[] }> }) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc} full={page.data.full}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
<DocsBody>
|
||||
<MDX components={getMDXComponents()} />
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return source.generateParams();
|
||||
return source.generateParams();
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: { params: Promise<{ slug?: string[] }> }) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import { source } from '@/lib/source';
|
||||
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
||||
import type { ReactNode } from 'react';
|
||||
import { baseOptions } from '@/app/layout.config';
|
||||
|
||||
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||
import type { ReactNode } from "react";
|
||||
import { baseOptions } from "@/app/layout.config";
|
||||
import { source } from "@/lib/source";
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<DocsLayout tree={source.pageTree} {...baseOptions}>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DocsLayout tree={source.pageTree} {...baseOptions}>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,160 +1,160 @@
|
|||
@import 'tailwindcss';
|
||||
@import 'fumadocs-ui/css/neutral.css';
|
||||
@import 'fumadocs-ui/css/preset.css';
|
||||
@import "tailwindcss";
|
||||
@import "fumadocs-ui/css/neutral.css";
|
||||
@import "fumadocs-ui/css/preset.css";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--syntax-bg: #f5f5f5;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--syntax-bg: #f5f5f5;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
--syntax-bg: #1e1e1e;
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
--syntax-bg: #1e1e1e;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
:root {
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
:root {
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
.dark {
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}'
|
||||
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
|
||||
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
||||
|
||||
export const baseOptions: BaseLayoutProps = {
|
||||
nav: {
|
||||
title: 'SurfSense Documentation',
|
||||
},
|
||||
};
|
||||
nav: {
|
||||
title: "SurfSense Documentation",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,108 +1,102 @@
|
|||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RootProvider } from "fumadocs-ui/provider";
|
||||
import { Roboto } from "next/font/google";
|
||||
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||
import { RootProvider } from 'fumadocs-ui/provider';
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const roboto = Roboto({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "700"],
|
||||
display: 'swap',
|
||||
variable: '--font-roboto',
|
||||
const roboto = Roboto({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "700"],
|
||||
display: "swap",
|
||||
variable: "--font-roboto",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SurfSense – Customizable AI Research & Knowledge Management Assistant",
|
||||
description:
|
||||
"SurfSense is an AI-powered research assistant that integrates with tools like Notion, GitHub, Slack, and more to help you efficiently manage, search, and chat with your documents. Generate podcasts, perform hybrid search, and unlock insights from your knowledge base.",
|
||||
keywords: [
|
||||
"SurfSense",
|
||||
"AI research assistant",
|
||||
"AI knowledge management",
|
||||
"AI document assistant",
|
||||
"customizable AI assistant",
|
||||
"notion integration",
|
||||
"slack integration",
|
||||
"github integration",
|
||||
"hybrid search",
|
||||
"vector search",
|
||||
"RAG",
|
||||
"LangChain",
|
||||
"FastAPI",
|
||||
"LLM apps",
|
||||
"AI document chat",
|
||||
"knowledge management AI",
|
||||
"AI-powered document search",
|
||||
"personal AI assistant",
|
||||
"AI research tools",
|
||||
"AI podcast generator",
|
||||
"AI knowledge base",
|
||||
"AI document assistant tools",
|
||||
"AI-powered search assistant",
|
||||
],
|
||||
openGraph: {
|
||||
title: "SurfSense – AI Research & Knowledge Management Assistant",
|
||||
description:
|
||||
"Connect your documents and tools like Notion, Slack, GitHub, and more to your private AI assistant. SurfSense offers powerful search, document chat, podcast generation, and RAG APIs to enhance your workflow.",
|
||||
url: "https://surfsense.net",
|
||||
siteName: "SurfSense",
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "https://surfsense.net/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "SurfSense AI Research Assistant",
|
||||
},
|
||||
],
|
||||
locale: "en_US",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "SurfSense – AI Assistant for Research & Knowledge Management",
|
||||
description:
|
||||
"Have your own NotebookLM or Perplexity, but better. SurfSense connects external tools, allows chat with your documents, and generates fast, high-quality podcasts.",
|
||||
creator: "https://surfsense.net",
|
||||
site: "https://surfsense.net",
|
||||
images: [
|
||||
{
|
||||
url: "https://surfsense.net/og-image-twitter.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "SurfSense AI Assistant Preview",
|
||||
},
|
||||
],
|
||||
}
|
||||
title: "SurfSense – Customizable AI Research & Knowledge Management Assistant",
|
||||
description:
|
||||
"SurfSense is an AI-powered research assistant that integrates with tools like Notion, GitHub, Slack, and more to help you efficiently manage, search, and chat with your documents. Generate podcasts, perform hybrid search, and unlock insights from your knowledge base.",
|
||||
keywords: [
|
||||
"SurfSense",
|
||||
"AI research assistant",
|
||||
"AI knowledge management",
|
||||
"AI document assistant",
|
||||
"customizable AI assistant",
|
||||
"notion integration",
|
||||
"slack integration",
|
||||
"github integration",
|
||||
"hybrid search",
|
||||
"vector search",
|
||||
"RAG",
|
||||
"LangChain",
|
||||
"FastAPI",
|
||||
"LLM apps",
|
||||
"AI document chat",
|
||||
"knowledge management AI",
|
||||
"AI-powered document search",
|
||||
"personal AI assistant",
|
||||
"AI research tools",
|
||||
"AI podcast generator",
|
||||
"AI knowledge base",
|
||||
"AI document assistant tools",
|
||||
"AI-powered search assistant",
|
||||
],
|
||||
openGraph: {
|
||||
title: "SurfSense – AI Research & Knowledge Management Assistant",
|
||||
description:
|
||||
"Connect your documents and tools like Notion, Slack, GitHub, and more to your private AI assistant. SurfSense offers powerful search, document chat, podcast generation, and RAG APIs to enhance your workflow.",
|
||||
url: "https://surfsense.net",
|
||||
siteName: "SurfSense",
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "https://surfsense.net/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "SurfSense AI Research Assistant",
|
||||
},
|
||||
],
|
||||
locale: "en_US",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "SurfSense – AI Assistant for Research & Knowledge Management",
|
||||
description:
|
||||
"Have your own NotebookLM or Perplexity, but better. SurfSense connects external tools, allows chat with your documents, and generates fast, high-quality podcasts.",
|
||||
creator: "https://surfsense.net",
|
||||
site: "https://surfsense.net",
|
||||
images: [
|
||||
{
|
||||
url: "https://surfsense.net/og-image-twitter.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "SurfSense AI Assistant Preview",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={cn(
|
||||
roboto.className,
|
||||
"bg-white dark:bg-black antialiased h-full w-full"
|
||||
)}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
defaultTheme="light"
|
||||
>
|
||||
<RootProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</RootProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={cn(roboto.className, "bg-white dark:bg-black antialiased h-full w-full")}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
defaultTheme="light"
|
||||
>
|
||||
<RootProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</RootProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,42 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
|
||||
export const AmbientBackground = () => {
|
||||
return (
|
||||
<div className="pointer-events-none absolute left-0 top-0 z-0 h-screen w-screen">
|
||||
<div
|
||||
style={{
|
||||
transform: "translateY(-350px) rotate(-45deg)",
|
||||
width: "560px",
|
||||
height: "1380px",
|
||||
background:
|
||||
"radial-gradient(68.54% 68.72% at 55.02% 31.46%, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.02) 50%, rgba(59, 130, 246, 0) 100%)",
|
||||
}}
|
||||
className="absolute left-0 top-0"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
transform: "rotate(-45deg) translate(5%, -50%)",
|
||||
transformOrigin: "top left",
|
||||
width: "240px",
|
||||
height: "1380px",
|
||||
background:
|
||||
"radial-gradient(50% 50% at 50% 50%, rgba(59, 130, 246, 0.06) 0%, rgba(59, 130, 246, 0.02) 80%, transparent 100%)",
|
||||
}}
|
||||
className="absolute left-0 top-0"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
borderRadius: "20px",
|
||||
transform: "rotate(-45deg) translate(-180%, -70%)",
|
||||
transformOrigin: "top left",
|
||||
width: "240px",
|
||||
height: "1380px",
|
||||
background:
|
||||
"radial-gradient(50% 50% at 50% 50%, rgba(59, 130, 246, 0.04) 0%, rgba(59, 130, 246, 0.02) 80%, transparent 100%)",
|
||||
}}
|
||||
className="absolute left-0 top-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="pointer-events-none absolute left-0 top-0 z-0 h-screen w-screen">
|
||||
<div
|
||||
style={{
|
||||
transform: "translateY(-350px) rotate(-45deg)",
|
||||
width: "560px",
|
||||
height: "1380px",
|
||||
background:
|
||||
"radial-gradient(68.54% 68.72% at 55.02% 31.46%, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.02) 50%, rgba(59, 130, 246, 0) 100%)",
|
||||
}}
|
||||
className="absolute left-0 top-0"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
transform: "rotate(-45deg) translate(5%, -50%)",
|
||||
transformOrigin: "top left",
|
||||
width: "240px",
|
||||
height: "1380px",
|
||||
background:
|
||||
"radial-gradient(50% 50% at 50% 50%, rgba(59, 130, 246, 0.06) 0%, rgba(59, 130, 246, 0.02) 80%, transparent 100%)",
|
||||
}}
|
||||
className="absolute left-0 top-0"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
borderRadius: "20px",
|
||||
transform: "rotate(-45deg) translate(-180%, -70%)",
|
||||
transformOrigin: "top left",
|
||||
width: "240px",
|
||||
height: "1380px",
|
||||
background:
|
||||
"radial-gradient(50% 50% at 50% 50%, rgba(59, 130, 246, 0.04) 0%, rgba(59, 130, 246, 0.02) 80%, transparent 100%)",
|
||||
}}
|
||||
className="absolute left-0 top-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,92 +1,99 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import { IconBrandGoogleFilled } from "@tabler/icons-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { AmbientBackground } from "./AmbientBackground";
|
||||
|
||||
export function GoogleLoginButton() {
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect to Google OAuth authorization URL
|
||||
fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get authorization URL');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.authorization_url) {
|
||||
window.location.href = data.authorization_url;
|
||||
} else {
|
||||
console.error('No authorization URL received');
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error during Google login:', error);
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<AmbientBackground />
|
||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||
<Logo className="rounded-md" />
|
||||
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
|
||||
Welcome Back
|
||||
</h1>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="mb-4 w-full overflow-hidden rounded-lg border border-yellow-200 bg-yellow-50 text-yellow-900 shadow-sm dark:border-yellow-900/30 dark:bg-yellow-900/20 dark:text-yellow-200"
|
||||
>
|
||||
<motion.div
|
||||
className="flex items-center gap-2 p-4"
|
||||
initial={{ x: -5 }}
|
||||
animate={{ x: 0 }}
|
||||
transition={{ delay: 0.1, duration: 0.2 }}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
<div className="ml-1">
|
||||
<p className="text-sm font-medium">
|
||||
SurfSense Cloud is currently in development. Check <a href="/docs" className="text-blue-600 underline dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">Docs</a> for more information on Self-Hosted version.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="group/btn relative flex w-full items-center justify-center space-x-2 rounded-lg bg-white px-6 py-4 text-neutral-700 shadow-lg transition-all duration-200 hover:shadow-xl dark:bg-neutral-800 dark:text-neutral-200"
|
||||
onClick={handleGoogleLogin}
|
||||
>
|
||||
<div className="absolute inset-0 h-full w-full transform opacity-0 transition duration-200 group-hover/btn:opacity-100">
|
||||
<div className="absolute -left-px -top-px h-4 w-4 rounded-tl-lg border-l-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-left-2 group-hover/btn:-top-2"></div>
|
||||
<div className="absolute -right-px -top-px h-4 w-4 rounded-tr-lg border-r-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-right-2 group-hover/btn:-top-2"></div>
|
||||
<div className="absolute -bottom-px -left-px h-4 w-4 rounded-bl-lg border-b-2 border-l-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-left-2"></div>
|
||||
<div className="absolute -bottom-px -right-px h-4 w-4 rounded-br-lg border-b-2 border-r-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-right-2"></div>
|
||||
</div>
|
||||
<IconBrandGoogleFilled className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
|
||||
<span className="text-base font-medium">Continue with Google</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect to Google OAuth authorization URL
|
||||
fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to get authorization URL");
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.authorization_url) {
|
||||
window.location.href = data.authorization_url;
|
||||
} else {
|
||||
console.error("No authorization URL received");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error during Google login:", error);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<AmbientBackground />
|
||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||
<Logo className="rounded-md" />
|
||||
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
|
||||
Welcome Back
|
||||
</h1>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="mb-4 w-full overflow-hidden rounded-lg border border-yellow-200 bg-yellow-50 text-yellow-900 shadow-sm dark:border-yellow-900/30 dark:bg-yellow-900/20 dark:text-yellow-200"
|
||||
>
|
||||
<motion.div
|
||||
className="flex items-center gap-2 p-4"
|
||||
initial={{ x: -5 }}
|
||||
animate={{ x: 0 }}
|
||||
transition={{ delay: 0.1, duration: 0.2 }}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<title>Google Logo</title>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
<div className="ml-1">
|
||||
<p className="text-sm font-medium">
|
||||
SurfSense Cloud is currently in development. Check{" "}
|
||||
<a
|
||||
href="/docs"
|
||||
className="text-blue-600 underline dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
||||
>
|
||||
Docs
|
||||
</a>{" "}
|
||||
for more information on Self-Hosted version.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="group/btn relative flex w-full items-center justify-center space-x-2 rounded-lg bg-white px-6 py-4 text-neutral-700 shadow-lg transition-all duration-200 hover:shadow-xl dark:bg-neutral-800 dark:text-neutral-200"
|
||||
onClick={handleGoogleLogin}
|
||||
>
|
||||
<div className="absolute inset-0 h-full w-full transform opacity-0 transition duration-200 group-hover/btn:opacity-100">
|
||||
<div className="absolute -left-px -top-px h-4 w-4 rounded-tl-lg border-l-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-left-2 group-hover/btn:-top-2"></div>
|
||||
<div className="absolute -right-px -top-px h-4 w-4 rounded-tr-lg border-r-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-right-2 group-hover/btn:-top-2"></div>
|
||||
<div className="absolute -bottom-px -left-px h-4 w-4 rounded-bl-lg border-b-2 border-l-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-left-2"></div>
|
||||
<div className="absolute -bottom-px -right-px h-4 w-4 rounded-br-lg border-b-2 border-r-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-right-2"></div>
|
||||
</div>
|
||||
<IconBrandGoogleFilled className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
|
||||
<span className="text-base font-medium">Continue with Google</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,114 +1,124 @@
|
|||
"use client";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function LocalLoginForm() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [authType, setAuthType] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [authType, setAuthType] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Get the auth type from environment variables
|
||||
setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE");
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
// Get the auth type from environment variables
|
||||
setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE");
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
// Create form data for the API request
|
||||
const formData = new URLSearchParams();
|
||||
formData.append("username", username);
|
||||
formData.append("password", password);
|
||||
formData.append("grant_type", "password");
|
||||
try {
|
||||
// Create form data for the API request
|
||||
const formData = new URLSearchParams();
|
||||
formData.append("username", username);
|
||||
formData.append("password", password);
|
||||
formData.append("grant_type", "password");
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/login`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: formData.toString(),
|
||||
}
|
||||
);
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/login`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: formData.toString(),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || "Failed to login");
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || "Failed to login");
|
||||
}
|
||||
|
||||
router.push("/auth/callback?token=" + data.access_token);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "An error occurred during login");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
router.push(`/auth/callback?token=${data.access_token}`);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "An error occurred during login";
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div className="w-full max-w-md">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{authType === "LOCAL" && (
|
||||
<div className="mt-4 text-center text-sm">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
||||
Register here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{authType === "LOCAL" && (
|
||||
<div className="mt-4 text-center text-sm">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Don'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,89 +1,89 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { GoogleLoginButton } from "./GoogleLoginButton";
|
||||
import { LocalLoginForm } from "./LocalLoginForm";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { AmbientBackground } from "./AmbientBackground";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { GoogleLoginButton } from "./GoogleLoginButton";
|
||||
import { LocalLoginForm } from "./LocalLoginForm";
|
||||
|
||||
function LoginContent() {
|
||||
const [authType, setAuthType] = useState<string | null>(null);
|
||||
const [registrationSuccess, setRegistrationSuccess] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const searchParams = useSearchParams();
|
||||
const [authType, setAuthType] = useState<string | null>(null);
|
||||
const [registrationSuccess, setRegistrationSuccess] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
// Check if the user was redirected from registration
|
||||
if (searchParams.get("registered") === "true") {
|
||||
setRegistrationSuccess(true);
|
||||
}
|
||||
useEffect(() => {
|
||||
// Check if the user was redirected from registration
|
||||
if (searchParams.get("registered") === "true") {
|
||||
setRegistrationSuccess(true);
|
||||
}
|
||||
|
||||
// Get the auth type from environment variables
|
||||
setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE");
|
||||
setIsLoading(false);
|
||||
}, [searchParams]);
|
||||
// Get the auth type from environment variables
|
||||
setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE");
|
||||
setIsLoading(false);
|
||||
}, [searchParams]);
|
||||
|
||||
// Show loading state while determining auth type
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<AmbientBackground />
|
||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||
<Logo className="rounded-md" />
|
||||
<div className="mt-8 flex items-center space-x-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Show loading state while determining auth type
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<AmbientBackground />
|
||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||
<Logo className="rounded-md" />
|
||||
<div className="mt-8 flex items-center space-x-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (authType === "GOOGLE") {
|
||||
return <GoogleLoginButton />;
|
||||
}
|
||||
if (authType === "GOOGLE") {
|
||||
return <GoogleLoginButton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<AmbientBackground />
|
||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||
<Logo className="rounded-md" />
|
||||
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
|
||||
Sign In
|
||||
</h1>
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<AmbientBackground />
|
||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||
<Logo className="rounded-md" />
|
||||
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
|
||||
Sign In
|
||||
</h1>
|
||||
|
||||
{registrationSuccess && (
|
||||
<div className="mb-4 w-full rounded-md bg-green-50 p-4 text-sm text-green-500 dark:bg-green-900/20 dark:text-green-200">
|
||||
Registration successful! You can now sign in with your credentials.
|
||||
</div>
|
||||
)}
|
||||
{registrationSuccess && (
|
||||
<div className="mb-4 w-full rounded-md bg-green-50 p-4 text-sm text-green-500 dark:bg-green-900/20 dark:text-green-200">
|
||||
Registration successful! You can now sign in with your credentials.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LocalLoginForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<LocalLoginForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading fallback for Suspense
|
||||
const LoadingFallback = () => (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<AmbientBackground />
|
||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||
<Logo className="rounded-md" />
|
||||
<div className="mt-8 flex items-center space-x-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<AmbientBackground />
|
||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||
<Logo className="rounded-md" />
|
||||
<div className="mt-8 flex items-center space-x-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<LoginContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<LoginContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,227 +1,238 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { CheckCircle, ArrowRight, ArrowLeft, Bot, Sparkles, Zap, Brain } from 'lucide-react';
|
||||
import { Logo } from '@/components/Logo';
|
||||
import { useLLMConfigs, useLLMPreferences } from '@/hooks/use-llm-configs';
|
||||
import { AddProviderStep } from '@/components/onboard/add-provider-step';
|
||||
import { AssignRolesStep } from '@/components/onboard/assign-roles-step';
|
||||
import { CompletionStep } from '@/components/onboard/completion-step';
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ArrowLeft, ArrowRight, Bot, CheckCircle, Sparkles } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { AddProviderStep } from "@/components/onboard/add-provider-step";
|
||||
import { AssignRolesStep } from "@/components/onboard/assign-roles-step";
|
||||
import { CompletionStep } from "@/components/onboard/completion-step";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||
|
||||
const TOTAL_STEPS = 3;
|
||||
|
||||
const OnboardPage = () => {
|
||||
const router = useRouter();
|
||||
const { llmConfigs, loading: configsLoading, refreshConfigs } = useLLMConfigs();
|
||||
const { preferences, loading: preferencesLoading, isOnboardingComplete, refreshPreferences } = useLLMPreferences();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [hasUserProgressed, setHasUserProgressed] = useState(false);
|
||||
const router = useRouter();
|
||||
const { llmConfigs, loading: configsLoading, refreshConfigs } = useLLMConfigs();
|
||||
const {
|
||||
preferences,
|
||||
loading: preferencesLoading,
|
||||
isOnboardingComplete,
|
||||
refreshPreferences,
|
||||
} = useLLMPreferences();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [hasUserProgressed, setHasUserProgressed] = useState(false);
|
||||
|
||||
// Check if user is authenticated
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('surfsense_bearer_token');
|
||||
if (!token) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
}, [router]);
|
||||
// Check if user is authenticated
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
if (!token) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// Track if user has progressed beyond step 1
|
||||
useEffect(() => {
|
||||
if (currentStep > 1) {
|
||||
setHasUserProgressed(true);
|
||||
}
|
||||
}, [currentStep]);
|
||||
// Track if user has progressed beyond step 1
|
||||
useEffect(() => {
|
||||
if (currentStep > 1) {
|
||||
setHasUserProgressed(true);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
// Redirect to dashboard if onboarding is already complete and user hasn't progressed (fresh page load)
|
||||
useEffect(() => {
|
||||
if (!preferencesLoading && isOnboardingComplete() && !hasUserProgressed) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [preferencesLoading, isOnboardingComplete, hasUserProgressed, router]);
|
||||
// Redirect to dashboard if onboarding is already complete and user hasn't progressed (fresh page load)
|
||||
useEffect(() => {
|
||||
if (!preferencesLoading && isOnboardingComplete() && !hasUserProgressed) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
}, [preferencesLoading, isOnboardingComplete, hasUserProgressed, router]);
|
||||
|
||||
const progress = (currentStep / TOTAL_STEPS) * 100;
|
||||
|
||||
const stepTitles = ["Add LLM Provider", "Assign LLM Roles", "Setup Complete"];
|
||||
|
||||
const progress = (currentStep / TOTAL_STEPS) * 100;
|
||||
const stepDescriptions = [
|
||||
"Configure your first model provider",
|
||||
"Assign specific roles to your LLM configurations",
|
||||
"You're all set to start using SurfSense!",
|
||||
];
|
||||
|
||||
const stepTitles = [
|
||||
"Add LLM Provider",
|
||||
"Assign LLM Roles",
|
||||
"Setup Complete"
|
||||
];
|
||||
const canProceedToStep2 = !configsLoading && llmConfigs.length > 0;
|
||||
const canProceedToStep3 =
|
||||
!preferencesLoading &&
|
||||
preferences.long_context_llm_id &&
|
||||
preferences.fast_llm_id &&
|
||||
preferences.strategic_llm_id;
|
||||
|
||||
const stepDescriptions = [
|
||||
"Configure your first model provider",
|
||||
"Assign specific roles to your LLM configurations",
|
||||
"You're all set to start using SurfSense!"
|
||||
];
|
||||
const handleNext = () => {
|
||||
if (currentStep < TOTAL_STEPS) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const canProceedToStep2 = !configsLoading && llmConfigs.length > 0;
|
||||
const canProceedToStep3 = !preferencesLoading && preferences.long_context_llm_id && preferences.fast_llm_id && preferences.strategic_llm_id;
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
router.push("/dashboard");
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < TOTAL_STEPS) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
if (configsLoading || preferencesLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Bot className="h-12 w-12 text-primary animate-pulse mb-4" />
|
||||
<p className="text-sm text-muted-foreground">Loading your configuration...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/20 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full max-w-4xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Logo className="w-12 h-12 mr-3" />
|
||||
<h1 className="text-3xl font-bold">Welcome to SurfSense</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Let's configure your SurfSense to get started
|
||||
</p>
|
||||
</div>
|
||||
|
||||
const handleComplete = () => {
|
||||
router.push('/dashboard');
|
||||
};
|
||||
{/* Progress */}
|
||||
<Card className="mb-8 bg-background/60 backdrop-blur-sm">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-sm font-medium">
|
||||
Step {currentStep} of {TOTAL_STEPS}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{Math.round(progress)}% Complete</div>
|
||||
</div>
|
||||
<Progress value={progress} className="mb-4" />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{Array.from({ length: TOTAL_STEPS }, (_, i) => {
|
||||
const stepNum = i + 1;
|
||||
const isCompleted = stepNum < currentStep;
|
||||
const isCurrent = stepNum === currentStep;
|
||||
|
||||
if (configsLoading || preferencesLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Bot className="h-12 w-12 text-primary animate-pulse mb-4" />
|
||||
<p className="text-sm text-muted-foreground">Loading your configuration...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={stepNum} className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
isCompleted
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isCurrent
|
||||
? "bg-primary/20 text-primary border-2 border-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? <CheckCircle className="w-4 h-4" /> : stepNum}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-sm font-medium truncate ${
|
||||
isCurrent ? "text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{stepTitles[i]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/20 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full max-w-4xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Logo className="w-12 h-12 mr-3" />
|
||||
<h1 className="text-3xl font-bold">Welcome to SurfSense</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-lg">Let's configure your SurfSense to get started</p>
|
||||
</div>
|
||||
{/* Step Content */}
|
||||
<Card className="min-h-[500px] bg-background/60 backdrop-blur-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl flex items-center justify-center gap-2">
|
||||
{currentStep === 1 && <Bot className="w-6 h-6" />}
|
||||
{currentStep === 2 && <Sparkles className="w-6 h-6" />}
|
||||
{currentStep === 3 && <CheckCircle className="w-6 h-6" />}
|
||||
{stepTitles[currentStep - 1]}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
{stepDescriptions[currentStep - 1]}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{currentStep === 1 && (
|
||||
<AddProviderStep
|
||||
onConfigCreated={refreshConfigs}
|
||||
onConfigDeleted={refreshConfigs}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && <AssignRolesStep onPreferencesUpdated={refreshPreferences} />}
|
||||
{currentStep === 3 && <CompletionStep />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Progress */}
|
||||
<Card className="mb-8 bg-background/60 backdrop-blur-sm">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-sm font-medium">Step {currentStep} of {TOTAL_STEPS}</div>
|
||||
<div className="text-sm text-muted-foreground">{Math.round(progress)}% Complete</div>
|
||||
</div>
|
||||
<Progress value={progress} className="mb-4" />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{Array.from({ length: TOTAL_STEPS }, (_, i) => {
|
||||
const stepNum = i + 1;
|
||||
const isCompleted = stepNum < currentStep;
|
||||
const isCurrent = stepNum === currentStep;
|
||||
|
||||
return (
|
||||
<div key={stepNum} className="flex items-center space-x-2">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
isCompleted
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: isCurrent
|
||||
? 'bg-primary/20 text-primary border-2 border-primary'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
{isCompleted ? <CheckCircle className="w-4 h-4" /> : stepNum}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium truncate ${
|
||||
isCurrent ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}>
|
||||
{stepTitles[i]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePrevious}
|
||||
disabled={currentStep === 1}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{/* Step Content */}
|
||||
<Card className="min-h-[500px] bg-background/60 backdrop-blur-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl flex items-center justify-center gap-2">
|
||||
{currentStep === 1 && <Bot className="w-6 h-6" />}
|
||||
{currentStep === 2 && <Sparkles className="w-6 h-6" />}
|
||||
{currentStep === 3 && <CheckCircle className="w-6 h-6" />}
|
||||
{stepTitles[currentStep - 1]}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
{stepDescriptions[currentStep - 1]}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{currentStep === 1 && <AddProviderStep onConfigCreated={refreshConfigs} onConfigDeleted={refreshConfigs} />}
|
||||
{currentStep === 2 && <AssignRolesStep onPreferencesUpdated={refreshPreferences} />}
|
||||
{currentStep === 3 && <CompletionStep />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex gap-2">
|
||||
{currentStep < TOTAL_STEPS && (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={
|
||||
(currentStep === 1 && !canProceedToStep2) ||
|
||||
(currentStep === 2 && !canProceedToStep3)
|
||||
}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Next
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePrevious}
|
||||
disabled={currentStep === 1}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{currentStep < TOTAL_STEPS && (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={
|
||||
(currentStep === 1 && !canProceedToStep2) ||
|
||||
(currentStep === 2 && !canProceedToStep3)
|
||||
}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Next
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{currentStep === TOTAL_STEPS && (
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Complete Setup
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
{currentStep === TOTAL_STEPS && (
|
||||
<Button onClick={handleComplete} className="flex items-center gap-2">
|
||||
Complete Setup
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardPage;
|
||||
export default OnboardPage;
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
"use client";
|
||||
import React from "react";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { motion } from "framer-motion";
|
||||
import { ModernHeroWithGradients } from "@/components/ModernHeroWithGradients";
|
||||
|
||||
import { Footer } from "@/components/Footer";
|
||||
import { ModernHeroWithGradients } from "@/components/ModernHeroWithGradients";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white">
|
||||
<Navbar />
|
||||
<ModernHeroWithGradients />
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<main className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white">
|
||||
<Navbar />
|
||||
<ModernHeroWithGradients />
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,146 +1,190 @@
|
|||
import { Metadata } from "next";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Privacy Policy | SurfSense",
|
||||
description: "Privacy Policy for SurfSense application",
|
||||
title: "Privacy Policy | SurfSense",
|
||||
description: "Privacy Policy for SurfSense application",
|
||||
};
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
return (
|
||||
<div className="container max-w-4xl mx-auto py-12 px-4">
|
||||
<h1 className="text-4xl font-bold mb-8">Privacy Policy</h1>
|
||||
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
<p className="text-lg mb-6">Last updated: {new Date().toLocaleDateString()}</p>
|
||||
return (
|
||||
<div className="container max-w-4xl mx-auto py-12 px-4">
|
||||
<h1 className="text-4xl font-bold mb-8">Privacy Policy</h1>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">1. Introduction</h2>
|
||||
<p>
|
||||
Welcome to SurfSense. We respect your privacy and are committed to protecting your personal data.
|
||||
This privacy policy will inform you about how we look after your personal data when you visit our
|
||||
website and tell you about your privacy rights and how the law protects you.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
By using our services, you acknowledge that you have read and understood this Privacy Policy. We reserve
|
||||
the right to modify this policy at any time, and such modifications shall be effective immediately upon
|
||||
posting the modified policy on this website.
|
||||
</p>
|
||||
</section>
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
<p className="text-lg mb-6">Last updated: {new Date().toLocaleDateString()}</p>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">2. Data We Collect</h2>
|
||||
<p>
|
||||
We may collect, use, store and transfer different kinds of personal data about you which we have
|
||||
grouped together as follows:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 my-4 space-y-2">
|
||||
<li><strong>Identity Data</strong> includes first name, last name, username or similar identifier.</li>
|
||||
<li><strong>Contact Data</strong> includes email address and telephone numbers.</li>
|
||||
<li><strong>Technical Data</strong> includes internet protocol (IP) address, your login data, browser type and version,
|
||||
time zone setting and location, browser plug-in types and versions, operating system and platform,
|
||||
and other technology on the devices you use to access this website.</li>
|
||||
<li><strong>Usage Data</strong> includes information about how you use our website and services.</li>
|
||||
<li><strong>Surf Data</strong> includes information about surf sessions, preferences, and equipment settings.</li>
|
||||
<li><strong>Marketing and Communications Data</strong> includes your preferences in receiving marketing from us and your communication preferences.</li>
|
||||
<li><strong>Aggregated Data</strong> which may be derived from your personal data but is not considered personal data as it does not directly or indirectly reveal your identity.</li>
|
||||
</ul>
|
||||
<p className="mt-4">
|
||||
We may also collect, use and share Aggregated Data such as statistical or demographic data for any purpose.
|
||||
Aggregated Data may be derived from your personal data but is not considered personal data in law as this data
|
||||
does not directly or indirectly reveal your identity.
|
||||
</p>
|
||||
</section>
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">1. Introduction</h2>
|
||||
<p>
|
||||
Welcome to SurfSense. We respect your privacy and are committed to protecting your
|
||||
personal data. This privacy policy will inform you about how we look after your personal
|
||||
data when you visit our website and tell you about your privacy rights and how the law
|
||||
protects you.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
By using our services, you acknowledge that you have read and understood this Privacy
|
||||
Policy. We reserve the right to modify this policy at any time, and such modifications
|
||||
shall be effective immediately upon posting the modified policy on this website.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">3. How We Use Your Data</h2>
|
||||
<p>
|
||||
We will only use your personal data when the law allows us to. Most commonly, we will use your
|
||||
personal data in the following circumstances:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 my-4 space-y-2">
|
||||
<li>Where we need to perform the contract we are about to enter into or have entered into with you.</li>
|
||||
<li>Where it is necessary for our legitimate interests (or those of a third party) and your interests
|
||||
and fundamental rights do not override those interests.</li>
|
||||
<li>Where we need to comply with a legal obligation.</li>
|
||||
<li>To provide and maintain our services, including to monitor the usage of our service.</li>
|
||||
<li>To improve our services, products, marketing, and customer relationships and experiences.</li>
|
||||
<li>To communicate with you about updates, security alerts, and support messages.</li>
|
||||
<li>To provide customer support and respond to your requests or inquiries.</li>
|
||||
<li>For business transfers, such as in connection with a merger, sale of company assets, financing, or acquisition.</li>
|
||||
</ul>
|
||||
<p className="mt-4">
|
||||
We may use your information for marketing purposes, such as sending you information about our products, services,
|
||||
promotions, and events. You can opt-out of receiving these communications at any time.
|
||||
</p>
|
||||
</section>
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">2. Data We Collect</h2>
|
||||
<p>
|
||||
We may collect, use, store and transfer different kinds of personal data about you which
|
||||
we have grouped together as follows:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 my-4 space-y-2">
|
||||
<li>
|
||||
<strong>Identity Data</strong> includes first name, last name, username or similar
|
||||
identifier.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Contact Data</strong> includes email address and telephone numbers.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Technical Data</strong> includes internet protocol (IP) address, your login
|
||||
data, browser type and version, time zone setting and location, browser plug-in types
|
||||
and versions, operating system and platform, and other technology on the devices you
|
||||
use to access this website.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Usage Data</strong> includes information about how you use our website and
|
||||
services.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Surf Data</strong> includes information about surf sessions, preferences, and
|
||||
equipment settings.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Marketing and Communications Data</strong> includes your preferences in
|
||||
receiving marketing from us and your communication preferences.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Aggregated Data</strong> which may be derived from your personal data but is
|
||||
not considered personal data as it does not directly or indirectly reveal your
|
||||
identity.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-4">
|
||||
We may also collect, use and share Aggregated Data such as statistical or demographic
|
||||
data for any purpose. Aggregated Data may be derived from your personal data but is not
|
||||
considered personal data in law as this data does not directly or indirectly reveal your
|
||||
identity.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">4. Data Security</h2>
|
||||
<p>
|
||||
We have put in place appropriate security measures to prevent your personal data from being
|
||||
accidentally lost, used or accessed in an unauthorized way, altered or disclosed. In addition,
|
||||
we limit access to your personal data to those employees, agents, contractors and other third
|
||||
parties who have a business need to know.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
While we implement safeguards designed to protect your information, no security system is impenetrable
|
||||
and due to the inherent nature of the Internet, we cannot guarantee that information, during transmission
|
||||
through the Internet or while stored on our systems, is absolutely safe from intrusion by others.
|
||||
</p>
|
||||
</section>
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">3. How We Use Your Data</h2>
|
||||
<p>
|
||||
We will only use your personal data when the law allows us to. Most commonly, we will
|
||||
use your personal data in the following circumstances:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 my-4 space-y-2">
|
||||
<li>
|
||||
Where we need to perform the contract we are about to enter into or have entered into
|
||||
with you.
|
||||
</li>
|
||||
<li>
|
||||
Where it is necessary for our legitimate interests (or those of a third party) and
|
||||
your interests and fundamental rights do not override those interests.
|
||||
</li>
|
||||
<li>Where we need to comply with a legal obligation.</li>
|
||||
<li>
|
||||
To provide and maintain our services, including to monitor the usage of our service.
|
||||
</li>
|
||||
<li>
|
||||
To improve our services, products, marketing, and customer relationships and
|
||||
experiences.
|
||||
</li>
|
||||
<li>To communicate with you about updates, security alerts, and support messages.</li>
|
||||
<li>To provide customer support and respond to your requests or inquiries.</li>
|
||||
<li>
|
||||
For business transfers, such as in connection with a merger, sale of company assets,
|
||||
financing, or acquisition.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-4">
|
||||
We may use your information for marketing purposes, such as sending you information
|
||||
about our products, services, promotions, and events. You can opt-out of receiving these
|
||||
communications at any time.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">5. Data Retention</h2>
|
||||
<p>
|
||||
We will only retain your personal data for as long as necessary to fulfill the purposes we collected it for,
|
||||
including for the purposes of satisfying any legal, accounting, or reporting requirements. To determine the appropriate
|
||||
retention period for personal data, we consider the amount, nature, and sensitivity of the personal data, the
|
||||
potential risk of harm from unauthorized use or disclosure of your personal data, the purposes for which we process
|
||||
your personal data and whether we can achieve those purposes through other means, and the applicable legal requirements.
|
||||
</p>
|
||||
</section>
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">4. Data Security</h2>
|
||||
<p>
|
||||
We have put in place appropriate security measures to prevent your personal data from
|
||||
being accidentally lost, used or accessed in an unauthorized way, altered or disclosed.
|
||||
In addition, we limit access to your personal data to those employees, agents,
|
||||
contractors and other third parties who have a business need to know.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
While we implement safeguards designed to protect your information, no security system
|
||||
is impenetrable and due to the inherent nature of the Internet, we cannot guarantee that
|
||||
information, during transmission through the Internet or while stored on our systems, is
|
||||
absolutely safe from intrusion by others.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">6. Your Legal Rights</h2>
|
||||
<p>
|
||||
Under certain circumstances, you have rights under data protection laws in relation to your personal data, including:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 my-4 space-y-2">
|
||||
<li>The right to request access to your personal data.</li>
|
||||
<li>The right to request correction of your personal data.</li>
|
||||
<li>The right to request erasure of your personal data.</li>
|
||||
<li>The right to object to processing of your personal data.</li>
|
||||
<li>The right to request restriction of processing your personal data.</li>
|
||||
<li>The right to request transfer of your personal data.</li>
|
||||
<li>The right to withdraw consent.</li>
|
||||
</ul>
|
||||
<p className="mt-4">
|
||||
Please note that these rights are not absolute, and we may be entitled to refuse requests where exceptions apply.
|
||||
If you wish to exercise any of the rights set out above, please contact us. We may need to request specific
|
||||
information from you to help us confirm your identity and ensure your right to access your personal data.
|
||||
</p>
|
||||
</section>
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">5. Data Retention</h2>
|
||||
<p>
|
||||
We will only retain your personal data for as long as necessary to fulfill the purposes
|
||||
we collected it for, including for the purposes of satisfying any legal, accounting, or
|
||||
reporting requirements. To determine the appropriate retention period for personal data,
|
||||
we consider the amount, nature, and sensitivity of the personal data, the potential risk
|
||||
of harm from unauthorized use or disclosure of your personal data, the purposes for
|
||||
which we process your personal data and whether we can achieve those purposes through
|
||||
other means, and the applicable legal requirements.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">7. Third-Party Services</h2>
|
||||
<p>
|
||||
Our service may contain links to other websites that are not operated by us. If you click on a third-party link,
|
||||
you will be directed to that third party's site. We strongly advise you to review the Privacy Policy of every site you visit.
|
||||
We have no control over and assume no responsibility for the content, privacy policies, or practices of any third-party
|
||||
sites or services.
|
||||
</p>
|
||||
</section>
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">6. Your Legal Rights</h2>
|
||||
<p>
|
||||
Under certain circumstances, you have rights under data protection laws in relation to
|
||||
your personal data, including:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 my-4 space-y-2">
|
||||
<li>The right to request access to your personal data.</li>
|
||||
<li>The right to request correction of your personal data.</li>
|
||||
<li>The right to request erasure of your personal data.</li>
|
||||
<li>The right to object to processing of your personal data.</li>
|
||||
<li>The right to request restriction of processing your personal data.</li>
|
||||
<li>The right to request transfer of your personal data.</li>
|
||||
<li>The right to withdraw consent.</li>
|
||||
</ul>
|
||||
<p className="mt-4">
|
||||
Please note that these rights are not absolute, and we may be entitled to refuse
|
||||
requests where exceptions apply. If you wish to exercise any of the rights set out
|
||||
above, please contact us. We may need to request specific information from you to help
|
||||
us confirm your identity and ensure your right to access your personal data.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">8. Contact Us</h2>
|
||||
<p>
|
||||
If you have any questions about this privacy policy or our privacy practices, please contact us at:
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<strong>Email:</strong> vermarohanfinal@gmail.com
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">7. Third-Party Services</h2>
|
||||
<p>
|
||||
Our service may contain links to other websites that are not operated by us. If you
|
||||
click on a third-party link, you will be directed to that third party's site. We
|
||||
strongly advise you to review the Privacy Policy of every site you visit. We have no
|
||||
control over and assume no responsibility for the content, privacy policies, or
|
||||
practices of any third-party sites or services.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">8. Contact Us</h2>
|
||||
<p>
|
||||
If you have any questions about this privacy policy or our privacy practices, please
|
||||
contact us at:
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<strong>Email:</strong> vermarohanfinal@gmail.com
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,149 +1,161 @@
|
|||
"use client";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { AmbientBackground } from "../login/AmbientBackground";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
// Check authentication type and redirect if not LOCAL
|
||||
useEffect(() => {
|
||||
const authType = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE";
|
||||
if (authType !== "LOCAL") {
|
||||
router.push("/login");
|
||||
}
|
||||
}, [router]);
|
||||
// Check authentication type and redirect if not LOCAL
|
||||
useEffect(() => {
|
||||
const authType = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE";
|
||||
if (authType !== "LOCAL") {
|
||||
router.push("/login");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Form validation
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
// Form validation
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
is_verified: false,
|
||||
}),
|
||||
}
|
||||
);
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
const data = await response.json();
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
is_verified: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || "Registration failed");
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Redirect to login page after successful registration
|
||||
router.push("/login?registered=true");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "An error occurred during registration");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || "Registration failed");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<AmbientBackground />
|
||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||
<Logo className="rounded-md" />
|
||||
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
|
||||
Create an Account
|
||||
</h1>
|
||||
// Redirect to login page after successful registration
|
||||
router.push("/login?registered=true");
|
||||
} catch (err: unknown) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "An error occurred during registration";
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<AmbientBackground />
|
||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||
<Logo className="rounded-md" />
|
||||
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
|
||||
Create an Account
|
||||
</h1>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full max-w-md">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? "Creating account..." : "Register"}
|
||||
</button>
|
||||
</form>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Already have an account?{" "}
|
||||
<Link href="/login" className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? "Creating account..." : "Register"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,72 +1,71 @@
|
|||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation'; // Add this import
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Bot, Settings, Brain, ArrowLeft } from 'lucide-react'; // Import ArrowLeft icon
|
||||
import { ModelConfigManager } from '@/components/settings/model-config-manager';
|
||||
import { LLMRoleManager } from '@/components/settings/llm-role-manager';
|
||||
import { ArrowLeft, Bot, Brain, Settings } from "lucide-react"; // Import ArrowLeft icon
|
||||
import { useRouter } from "next/navigation"; // Add this import
|
||||
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
|
||||
import { ModelConfigManager } from "@/components/settings/model-config-manager";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter(); // Initialize router
|
||||
const router = useRouter(); // Initialize router
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container max-w-7xl mx-auto p-6 lg:p-8">
|
||||
<div className="space-y-8">
|
||||
{/* Header Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 hover:bg-primary/20 transition-colors"
|
||||
aria-label="Back to Dashboard"
|
||||
type="button"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 text-primary" />
|
||||
</button>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Settings className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Manage your LLM configurations and role assignments.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-6" />
|
||||
</div>
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container max-w-7xl mx-auto p-6 lg:p-8">
|
||||
<div className="space-y-8">
|
||||
{/* Header Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={() => router.push("/dashboard")}
|
||||
className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 hover:bg-primary/20 transition-colors"
|
||||
aria-label="Back to Dashboard"
|
||||
type="button"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 text-primary" />
|
||||
</button>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Settings className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Manage your LLM configurations and role assignments.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-6" />
|
||||
</div>
|
||||
|
||||
{/* Settings Content */}
|
||||
<Tabs defaultValue="models" className="space-y-8">
|
||||
<div className="overflow-x-auto">
|
||||
<TabsList className="grid w-full min-w-fit grid-cols-2 lg:w-auto lg:inline-grid">
|
||||
<TabsTrigger value="models" className="flex items-center gap-2 text-sm">
|
||||
<Bot className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Model Configs</span>
|
||||
<span className="sm:hidden">Models</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="roles" className="flex items-center gap-2 text-sm">
|
||||
<Brain className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">LLM Roles</span>
|
||||
<span className="sm:hidden">Roles</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
{/* Settings Content */}
|
||||
<Tabs defaultValue="models" className="space-y-8">
|
||||
<div className="overflow-x-auto">
|
||||
<TabsList className="grid w-full min-w-fit grid-cols-2 lg:w-auto lg:inline-grid">
|
||||
<TabsTrigger value="models" className="flex items-center gap-2 text-sm">
|
||||
<Bot className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Model Configs</span>
|
||||
<span className="sm:hidden">Models</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="roles" className="flex items-center gap-2 text-sm">
|
||||
<Brain className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">LLM Roles</span>
|
||||
<span className="sm:hidden">Roles</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="models" className="space-y-6">
|
||||
<ModelConfigManager />
|
||||
</TabsContent>
|
||||
<TabsContent value="models" className="space-y-6">
|
||||
<ModelConfigManager />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="roles" className="space-y-6">
|
||||
<LLMRoleManager />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<TabsContent value="roles" className="space-y-6">
|
||||
<LLMRoleManager />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,48 @@
|
|||
import type { MetadataRoute } from 'next'
|
||||
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: 'https://www.surfsense.net/',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: 'https://www.surfsense.net/privacy',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: 'https://www.surfsense.net/terms',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: 'https://www.surfsense.net/docs',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: 'https://www.surfsense.net/docs/installation',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: 'https://www.surfsense.net/docs/docker-installation',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: 'https://www.surfsense.net/docs/manual-installation',
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.9,
|
||||
},
|
||||
]
|
||||
return [
|
||||
{
|
||||
url: "https://www.surfsense.net/",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "yearly",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.net/privacy",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.net/terms",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.net/docs",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.net/docs/installation",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.net/docs/docker-installation",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.net/docs/manual-installation",
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.9,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,204 +1,225 @@
|
|||
import { Metadata } from "next";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Terms of Service | SurfSense",
|
||||
description: "Terms of Service for SurfSense application",
|
||||
title: "Terms of Service | SurfSense",
|
||||
description: "Terms of Service for SurfSense application",
|
||||
};
|
||||
|
||||
export default function TermsOfService() {
|
||||
return (
|
||||
<div className="container max-w-4xl mx-auto py-12 px-4">
|
||||
<h1 className="text-4xl font-bold mb-8">Terms of Service</h1>
|
||||
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
<p className="text-lg mb-6">Last updated: {new Date().toLocaleDateString()}</p>
|
||||
return (
|
||||
<div className="container max-w-4xl mx-auto py-12 px-4">
|
||||
<h1 className="text-4xl font-bold mb-8">Terms of Service</h1>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">1. Introduction</h2>
|
||||
<p>
|
||||
Welcome to SurfSense. These Terms of Service govern your access to and use of the SurfSense website and services.
|
||||
By accessing or using our services, you agree to be bound by these Terms.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
Please read these Terms carefully before using our Services. By using our Services, you agree that these Terms
|
||||
will govern your relationship with us. If you do not agree to these Terms, please refrain from using our Services.
|
||||
</p>
|
||||
</section>
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
<p className="text-lg mb-6">Last updated: {new Date().toLocaleDateString()}</p>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">2. Using Our Services</h2>
|
||||
<p>
|
||||
You must follow any policies made available to you within the Services. You may use our Services only as
|
||||
permitted by law. We may suspend or stop providing our Services to you if you do not comply with our terms or
|
||||
policies or if we are investigating suspected misconduct.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
Using our Services does not give you ownership of any intellectual property rights in our Services or the
|
||||
content you access. You may not use content from our Services unless you obtain permission from its owner or
|
||||
are otherwise permitted by law.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
We reserve the right to remove any content that we reasonably believe violates these Terms, infringes any
|
||||
intellectual property right, is abusive, illegal, or otherwise objectionable.
|
||||
</p>
|
||||
</section>
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">1. Introduction</h2>
|
||||
<p>
|
||||
Welcome to SurfSense. These Terms of Service govern your access to and use of the
|
||||
SurfSense website and services. By accessing or using our services, you agree to be
|
||||
bound by these Terms.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
Please read these Terms carefully before using our Services. By using our Services, you
|
||||
agree that these Terms will govern your relationship with us. If you do not agree to
|
||||
these Terms, please refrain from using our Services.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">3. Your Account</h2>
|
||||
<p>
|
||||
To use some of our services, you may need to create an account. You are responsible for safeguarding the
|
||||
password that you use to access the services and for any activities or actions under your password.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
You must provide accurate and complete information when creating your account. You agree to update your
|
||||
information to keep it accurate and complete. You are responsible for maintaining the confidentiality of your
|
||||
account and password, including restricting access to your computer and/or account.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
We reserve the right to refuse service, terminate accounts, remove or edit content, or cancel orders at
|
||||
our sole discretion.
|
||||
</p>
|
||||
</section>
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">2. Using Our Services</h2>
|
||||
<p>
|
||||
You must follow any policies made available to you within the Services. You may use our
|
||||
Services only as permitted by law. We may suspend or stop providing our Services to you
|
||||
if you do not comply with our terms or policies or if we are investigating suspected
|
||||
misconduct.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
Using our Services does not give you ownership of any intellectual property rights in
|
||||
our Services or the content you access. You may not use content from our Services unless
|
||||
you obtain permission from its owner or are otherwise permitted by law.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
We reserve the right to remove any content that we reasonably believe violates these
|
||||
Terms, infringes any intellectual property right, is abusive, illegal, or otherwise
|
||||
objectionable.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">4. Privacy and Copyright Protection</h2>
|
||||
<p>
|
||||
Our privacy policies explain how we treat your personal data and protect your privacy when you use our
|
||||
Services. By using our Services, you agree that SurfSense can use such data in accordance with our privacy policies.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
We respond to notices of alleged copyright infringement and terminate accounts of repeat infringers according
|
||||
to the process set out in applicable copyright laws.
|
||||
</p>
|
||||
</section>
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">3. Your Account</h2>
|
||||
<p>
|
||||
To use some of our services, you may need to create an account. You are responsible for
|
||||
safeguarding the password that you use to access the services and for any activities or
|
||||
actions under your password.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
You must provide accurate and complete information when creating your account. You agree
|
||||
to update your information to keep it accurate and complete. You are responsible for
|
||||
maintaining the confidentiality of your account and password, including restricting
|
||||
access to your computer and/or account.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
We reserve the right to refuse service, terminate accounts, remove or edit content, or
|
||||
cancel orders at our sole discretion.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">5. License and Intellectual Property</h2>
|
||||
<p>
|
||||
SurfSense gives you a personal, worldwide, royalty-free, non-assignable and non-exclusive license to use the
|
||||
software provided to you as part of the Services. This license is for the sole purpose of enabling you to use
|
||||
and enjoy the benefit of the Services as provided by SurfSense, in the manner permitted by these terms.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
All content included in or made available through our Services—such as text, graphics, logos, button icons,
|
||||
images, audio clips, digital downloads, data compilations, and software—is the property of SurfSense or its
|
||||
content suppliers and is protected by international copyright, trademark, and other intellectual property laws.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
By submitting, posting, or displaying content on or through our Services, you grant us a worldwide, non-exclusive,
|
||||
royalty-free license to use, reproduce, modify, adapt, publish, translate, create derivative works from, distribute,
|
||||
and display such content in any media for the purpose of providing and improving our Services.
|
||||
</p>
|
||||
</section>
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">4. Privacy and Copyright Protection</h2>
|
||||
<p>
|
||||
Our privacy policies explain how we treat your personal data and protect your privacy
|
||||
when you use our Services. By using our Services, you agree that SurfSense can use such
|
||||
data in accordance with our privacy policies.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
We respond to notices of alleged copyright infringement and terminate accounts of repeat
|
||||
infringers according to the process set out in applicable copyright laws.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">6. Modifying and Terminating our Services</h2>
|
||||
<p>
|
||||
We are constantly changing and improving our Services. We may add or remove functionalities or features, and
|
||||
we may suspend or stop a Service altogether. You can stop using our Services at any time. SurfSense may also
|
||||
stop providing Services to you, or add or create new limits on our Services at any time.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
We believe that you own your data and preserving your access to such data is important. If we discontinue a Service,
|
||||
where reasonably possible, we will give you reasonable advance notice and a chance to get information out of that Service.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
We reserve the right to modify these Terms at any time. If we make material changes to these Terms, we will notify
|
||||
you by email or by posting a notice on our website before the changes become effective. Your continued use of our
|
||||
Services after the effective date of such changes constitutes your acceptance of the modified Terms.
|
||||
</p>
|
||||
</section>
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">5. License and Intellectual Property</h2>
|
||||
<p>
|
||||
SurfSense gives you a personal, worldwide, royalty-free, non-assignable and
|
||||
non-exclusive license to use the software provided to you as part of the Services. This
|
||||
license is for the sole purpose of enabling you to use and enjoy the benefit of the
|
||||
Services as provided by SurfSense, in the manner permitted by these terms.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
All content included in or made available through our Services—such as text, graphics,
|
||||
logos, button icons, images, audio clips, digital downloads, data compilations, and
|
||||
software—is the property of SurfSense or its content suppliers and is protected by
|
||||
international copyright, trademark, and other intellectual property laws.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
By submitting, posting, or displaying content on or through our Services, you grant us a
|
||||
worldwide, non-exclusive, royalty-free license to use, reproduce, modify, adapt,
|
||||
publish, translate, create derivative works from, distribute, and display such content
|
||||
in any media for the purpose of providing and improving our Services.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">7. Warranties and Disclaimers</h2>
|
||||
<p>
|
||||
We provide our Services using a commercially reasonable level of skill and care and we hope that you will
|
||||
enjoy using them. But there are certain things that we don't promise about our Services.
|
||||
</p>
|
||||
<p className="mt-4 uppercase font-bold">
|
||||
OTHER THAN AS EXPRESSLY SET OUT IN THESE TERMS OR ADDITIONAL TERMS, NEITHER SURFSENSE NOR ITS SUPPLIERS OR DISTRIBUTORS
|
||||
MAKE ANY SPECIFIC PROMISES ABOUT THE SERVICES. FOR EXAMPLE, WE DON'T MAKE ANY COMMITMENTS ABOUT THE CONTENT WITHIN THE
|
||||
SERVICES, THE SPECIFIC FUNCTIONS OF THE SERVICES, OR THEIR RELIABILITY, AVAILABILITY, OR ABILITY TO MEET YOUR NEEDS.
|
||||
WE PROVIDE THE SERVICES "AS IS".
|
||||
</p>
|
||||
<p className="mt-4 uppercase font-bold">
|
||||
SOME JURISDICTIONS PROVIDE FOR CERTAIN WARRANTIES, LIKE THE IMPLIED WARRANTY OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE, AND NON-INFRINGEMENT. TO THE EXTENT PERMITTED BY LAW, WE EXCLUDE ALL WARRANTIES.
|
||||
</p>
|
||||
</section>
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">6. Modifying and Terminating our Services</h2>
|
||||
<p>
|
||||
We are constantly changing and improving our Services. We may add or remove
|
||||
functionalities or features, and we may suspend or stop a Service altogether. You can
|
||||
stop using our Services at any time. SurfSense may also stop providing Services to you,
|
||||
or add or create new limits on our Services at any time.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
We believe that you own your data and preserving your access to such data is important.
|
||||
If we discontinue a Service, where reasonably possible, we will give you reasonable
|
||||
advance notice and a chance to get information out of that Service.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
We reserve the right to modify these Terms at any time. If we make material changes to
|
||||
these Terms, we will notify you by email or by posting a notice on our website before
|
||||
the changes become effective. Your continued use of our Services after the effective
|
||||
date of such changes constitutes your acceptance of the modified Terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">8. Liability for our Services</h2>
|
||||
<p className="uppercase font-bold">
|
||||
WHEN PERMITTED BY LAW, SURFSENSE, AND SURFSENSE'S SUPPLIERS AND DISTRIBUTORS, WILL NOT BE RESPONSIBLE FOR
|
||||
LOST PROFITS, REVENUES, OR DATA, FINANCIAL LOSSES OR INDIRECT, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR
|
||||
PUNITIVE DAMAGES.
|
||||
</p>
|
||||
<p className="mt-4 uppercase font-bold">
|
||||
TO THE EXTENT PERMITTED BY LAW, THE TOTAL LIABILITY OF SURFSENSE, AND ITS SUPPLIERS AND DISTRIBUTORS, FOR ANY
|
||||
CLAIMS UNDER THESE TERMS, INCLUDING FOR ANY IMPLIED WARRANTIES, IS LIMITED TO THE AMOUNT YOU PAID US TO USE THE
|
||||
SERVICES (OR, IF WE CHOOSE, TO SUPPLYING YOU THE SERVICES AGAIN).
|
||||
</p>
|
||||
<p className="mt-4 uppercase font-bold">
|
||||
IN ALL CASES, SURFSENSE, AND ITS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE LIABLE FOR ANY LOSS OR DAMAGE THAT IS
|
||||
NOT REASONABLY FORESEEABLE.
|
||||
</p>
|
||||
</section>
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">7. Warranties and Disclaimers</h2>
|
||||
<p>
|
||||
We provide our Services using a commercially reasonable level of skill and care and we
|
||||
hope that you will enjoy using them. But there are certain things that we don't promise
|
||||
about our Services.
|
||||
</p>
|
||||
<p className="mt-4 uppercase font-bold">
|
||||
OTHER THAN AS EXPRESSLY SET OUT IN THESE TERMS OR ADDITIONAL TERMS, NEITHER SURFSENSE
|
||||
NOR ITS SUPPLIERS OR DISTRIBUTORS MAKE ANY SPECIFIC PROMISES ABOUT THE SERVICES. FOR
|
||||
EXAMPLE, WE DON'T MAKE ANY COMMITMENTS ABOUT THE CONTENT WITHIN THE SERVICES, THE
|
||||
SPECIFIC FUNCTIONS OF THE SERVICES, OR THEIR RELIABILITY, AVAILABILITY, OR ABILITY TO
|
||||
MEET YOUR NEEDS. WE PROVIDE THE SERVICES "AS IS".
|
||||
</p>
|
||||
<p className="mt-4 uppercase font-bold">
|
||||
SOME JURISDICTIONS PROVIDE FOR CERTAIN WARRANTIES, LIKE THE IMPLIED WARRANTY OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. TO THE EXTENT
|
||||
PERMITTED BY LAW, WE EXCLUDE ALL WARRANTIES.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">9. Indemnification</h2>
|
||||
<p>
|
||||
You agree to defend, indemnify, and hold harmless SurfSense, its affiliates, and their respective officers, directors,
|
||||
employees, and agents from and against any claims, liabilities, damages, judgments, awards, losses, costs, expenses, or
|
||||
fees (including reasonable attorneys' fees) arising out of or relating to your violation of these Terms or your use of
|
||||
the Services, including, but not limited to, any use of the Services' content, services, and products other than as
|
||||
expressly authorized in these Terms.
|
||||
</p>
|
||||
</section>
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">8. Liability for our Services</h2>
|
||||
<p className="uppercase font-bold">
|
||||
WHEN PERMITTED BY LAW, SURFSENSE, AND SURFSENSE'S SUPPLIERS AND DISTRIBUTORS, WILL NOT
|
||||
BE RESPONSIBLE FOR LOST PROFITS, REVENUES, OR DATA, FINANCIAL LOSSES OR INDIRECT,
|
||||
SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES.
|
||||
</p>
|
||||
<p className="mt-4 uppercase font-bold">
|
||||
TO THE EXTENT PERMITTED BY LAW, THE TOTAL LIABILITY OF SURFSENSE, AND ITS SUPPLIERS AND
|
||||
DISTRIBUTORS, FOR ANY CLAIMS UNDER THESE TERMS, INCLUDING FOR ANY IMPLIED WARRANTIES, IS
|
||||
LIMITED TO THE AMOUNT YOU PAID US TO USE THE SERVICES (OR, IF WE CHOOSE, TO SUPPLYING
|
||||
YOU THE SERVICES AGAIN).
|
||||
</p>
|
||||
<p className="mt-4 uppercase font-bold">
|
||||
IN ALL CASES, SURFSENSE, AND ITS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE LIABLE FOR ANY
|
||||
LOSS OR DAMAGE THAT IS NOT REASONABLY FORESEEABLE.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">10. Dispute Resolution</h2>
|
||||
<p>
|
||||
Any dispute arising out of or relating to these Terms, including the validity, interpretation, breach, or termination
|
||||
thereof, shall be resolved by arbitration in accordance with the rules of the arbitration authority in the jurisdiction
|
||||
where SurfSense operates. The arbitration shall be conducted by one arbitrator, in the English language, and the
|
||||
decision of the arbitrator shall be final and binding on the parties.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
You agree that any dispute resolution proceedings will be conducted only on an individual basis and not in a class,
|
||||
consolidated, or representative action. If for any reason a claim proceeds in court rather than in arbitration, you
|
||||
waive any right to a jury trial.
|
||||
</p>
|
||||
</section>
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">9. Indemnification</h2>
|
||||
<p>
|
||||
You agree to defend, indemnify, and hold harmless SurfSense, its affiliates, and their
|
||||
respective officers, directors, employees, and agents from and against any claims,
|
||||
liabilities, damages, judgments, awards, losses, costs, expenses, or fees (including
|
||||
reasonable attorneys' fees) arising out of or relating to your violation of these Terms
|
||||
or your use of the Services, including, but not limited to, any use of the Services'
|
||||
content, services, and products other than as expressly authorized in these Terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">11. About these Terms</h2>
|
||||
<p>
|
||||
We may modify these terms or any additional terms that apply to a Service to, for example, reflect changes to
|
||||
the law or changes to our Services. You should look at the terms regularly. If you do not agree to the modified
|
||||
terms for a Service, you should discontinue your use of that Service.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
If there is a conflict between these terms and the additional terms, the additional terms will control for that conflict.
|
||||
These terms control the relationship between SurfSense and you. They do not create any third-party beneficiary rights.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
If you do not comply with these terms, and we don't take action right away, this doesn't mean that we are giving up
|
||||
any rights that we may have (such as taking action in the future). If it turns out that a particular term is not
|
||||
enforceable, this will not affect any other terms.
|
||||
</p>
|
||||
</section>
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">10. Dispute Resolution</h2>
|
||||
<p>
|
||||
Any dispute arising out of or relating to these Terms, including the validity,
|
||||
interpretation, breach, or termination thereof, shall be resolved by arbitration in
|
||||
accordance with the rules of the arbitration authority in the jurisdiction where
|
||||
SurfSense operates. The arbitration shall be conducted by one arbitrator, in the English
|
||||
language, and the decision of the arbitrator shall be final and binding on the parties.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
You agree that any dispute resolution proceedings will be conducted only on an
|
||||
individual basis and not in a class, consolidated, or representative action. If for any
|
||||
reason a claim proceeds in court rather than in arbitration, you waive any right to a
|
||||
jury trial.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">12. Contact Us</h2>
|
||||
<p>
|
||||
If you have any questions about these Terms, please contact us at:
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<strong>Email:</strong> vermarohanfinal@gmail.com
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">11. About these Terms</h2>
|
||||
<p>
|
||||
We may modify these terms or any additional terms that apply to a Service to, for
|
||||
example, reflect changes to the law or changes to our Services. You should look at the
|
||||
terms regularly. If you do not agree to the modified terms for a Service, you should
|
||||
discontinue your use of that Service.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
If there is a conflict between these terms and the additional terms, the additional
|
||||
terms will control for that conflict. These terms control the relationship between
|
||||
SurfSense and you. They do not create any third-party beneficiary rights.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
If you do not comply with these terms, and we don't take action right away, this doesn't
|
||||
mean that we are giving up any rights that we may have (such as taking action in the
|
||||
future). If it turns out that a particular term is not enforceable, this will not affect
|
||||
any other terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-semibold mb-4">12. Contact Us</h2>
|
||||
<p>If you have any questions about these Terms, please contact us at:</p>
|
||||
<p className="mt-2">
|
||||
<strong>Email:</strong> vermarohanfinal@gmail.com
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
115
surfsense_web/biome.json
Normal file
115
surfsense_web/biome.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,21 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,106 +1,97 @@
|
|||
"use client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
IconBrandDiscord,
|
||||
IconBrandGithub,
|
||||
IconBrandLinkedin,
|
||||
IconBrandTwitter,
|
||||
IconBrandDiscord,
|
||||
IconBrandGithub,
|
||||
IconBrandLinkedin,
|
||||
IconBrandTwitter,
|
||||
} from "@tabler/icons-react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Footer() {
|
||||
const pages = [
|
||||
{
|
||||
title: "Privacy",
|
||||
href: "/privacy",
|
||||
},
|
||||
{
|
||||
title: "Terms",
|
||||
href: "/terms",
|
||||
},
|
||||
];
|
||||
const pages = [
|
||||
{
|
||||
title: "Privacy",
|
||||
href: "/privacy",
|
||||
},
|
||||
{
|
||||
title: "Terms",
|
||||
href: "/terms",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="border-t border-neutral-100 dark:border-white/[0.1] px-8 py-20 bg-white dark:bg-neutral-950 w-full relative overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto text-sm text-neutral-500 justify-between items-start md:px-8">
|
||||
<div className="flex flex-col items-center justify-center w-full relative">
|
||||
<div className="mr-0 md:mr-4 md:flex mb-4">
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium text-black dark:text-white ml-2">SurfSense</span>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="border-t border-neutral-100 dark:border-white/[0.1] px-8 py-20 bg-white dark:bg-neutral-950 w-full relative overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto text-sm text-neutral-500 justify-between items-start md:px-8">
|
||||
<div className="flex flex-col items-center justify-center w-full relative">
|
||||
<div className="mr-0 md:mr-4 md:flex mb-4">
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium text-black dark:text-white ml-2">SurfSense</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="transition-colors flex sm:flex-row flex-col hover:text-text-neutral-800 text-neutral-600 dark:text-neutral-300 list-none gap-4">
|
||||
{pages.map((page, idx) => (
|
||||
<li key={"pages" + idx} className="list-none">
|
||||
<Link
|
||||
className="transition-colors hover:text-text-neutral-800"
|
||||
href={page.href}
|
||||
>
|
||||
{page.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul className="transition-colors flex sm:flex-row flex-col hover:text-text-neutral-800 text-neutral-600 dark:text-neutral-300 list-none gap-4">
|
||||
{pages.map((page) => (
|
||||
<li key={`pages-${page.title}`} className="list-none">
|
||||
<Link className="transition-colors hover:text-text-neutral-800" href={page.href}>
|
||||
{page.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<GridLineHorizontal className="max-w-7xl mx-auto mt-8" />
|
||||
</div>
|
||||
<div className="flex sm:flex-row flex-col justify-between mt-8 items-center w-full">
|
||||
<p className="text-neutral-500 dark:text-neutral-400 mb-8 sm:mb-0">
|
||||
© SurfSense 2025
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Link href="https://x.com/mod_setter">
|
||||
<IconBrandTwitter className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||
</Link>
|
||||
<Link href="https://www.linkedin.com/in/rohan-verma-sde/">
|
||||
<IconBrandLinkedin className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||
</Link>
|
||||
<Link href="https://github.com/MODSetter">
|
||||
<IconBrandGithub className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||
</Link>
|
||||
<Link href="https://discord.gg/ejRNvftDp9">
|
||||
<IconBrandDiscord className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<GridLineHorizontal className="max-w-7xl mx-auto mt-8" />
|
||||
</div>
|
||||
<div className="flex sm:flex-row flex-col justify-between mt-8 items-center w-full">
|
||||
<p className="text-neutral-500 dark:text-neutral-400 mb-8 sm:mb-0">
|
||||
© SurfSense 2025
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Link href="https://x.com/mod_setter">
|
||||
<IconBrandTwitter className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||
</Link>
|
||||
<Link href="https://www.linkedin.com/in/rohan-verma-sde/">
|
||||
<IconBrandLinkedin className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||
</Link>
|
||||
<Link href="https://github.com/MODSetter">
|
||||
<IconBrandGithub className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||
</Link>
|
||||
<Link href="https://discord.gg/ejRNvftDp9">
|
||||
<IconBrandDiscord className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const GridLineHorizontal = ({
|
||||
className,
|
||||
offset,
|
||||
}: {
|
||||
className?: string;
|
||||
offset?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--background": "#ffffff",
|
||||
"--color": "rgba(0, 0, 0, 0.2)",
|
||||
"--height": "1px",
|
||||
"--width": "5px",
|
||||
"--fade-stop": "90%",
|
||||
"--offset": offset || "200px", //-100px if you want to keep the line inside
|
||||
"--color-dark": "rgba(255, 255, 255, 0.2)",
|
||||
maskComposite: "exclude",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"w-[calc(100%+var(--offset))] h-[var(--height)]",
|
||||
"bg-[linear-gradient(to_right,var(--color),var(--color)_50%,transparent_0,transparent)]",
|
||||
"[background-size:var(--width)_var(--height)]",
|
||||
"[mask:linear-gradient(to_left,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_right,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
|
||||
"[mask-composite:exclude]",
|
||||
"z-30",
|
||||
"dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
|
||||
className
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
const GridLineHorizontal = ({ className, offset }: { className?: string; offset?: string }) => {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--background": "#ffffff",
|
||||
"--color": "rgba(0, 0, 0, 0.2)",
|
||||
"--height": "1px",
|
||||
"--width": "5px",
|
||||
"--fade-stop": "90%",
|
||||
"--offset": offset || "200px", //-100px if you want to keep the line inside
|
||||
"--color-dark": "rgba(255, 255, 255, 0.2)",
|
||||
maskComposite: "exclude",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"w-[calc(100%+var(--offset))] h-[var(--height)]",
|
||||
"bg-[linear-gradient(to_right,var(--color),var(--color)_50%,transparent_0,transparent)]",
|
||||
"[background-size:var(--width)_var(--height)]",
|
||||
"[mask:linear-gradient(to_left,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_right,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
|
||||
"[mask-composite:exclude]",
|
||||
"z-30",
|
||||
"dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
|
||||
className
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,22 +1,13 @@
|
|||
"use client";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Logo = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<Link
|
||||
href="/"
|
||||
>
|
||||
<Image
|
||||
src="/icon-128.png"
|
||||
className={cn(className)}
|
||||
alt="logo"
|
||||
width={128}
|
||||
height={128}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<Link href="/">
|
||||
<Image src="/icon-128.png" className={cn(className)} alt="logo" width={128} height={128} />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,290 +1,286 @@
|
|||
"use client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconMenu2, IconX, IconBrandGoogleFilled, IconUser } from "@tabler/icons-react";
|
||||
import {
|
||||
motion,
|
||||
AnimatePresence,
|
||||
useScroll,
|
||||
useMotionValueEvent,
|
||||
} from "framer-motion";
|
||||
import { IconMenu2, IconUser, IconX } from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion, useMotionValueEvent, useScroll } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "./Logo";
|
||||
import { ThemeTogglerComponent } from "./theme/theme-toggle";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface NavbarProps {
|
||||
navItems: {
|
||||
name: string;
|
||||
link: string;
|
||||
}[];
|
||||
visible: boolean;
|
||||
navItems: {
|
||||
name: string;
|
||||
link: string;
|
||||
}[];
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export const Navbar = () => {
|
||||
const navItems = [
|
||||
{
|
||||
name: "Docs",
|
||||
link: "/docs",
|
||||
},
|
||||
// {
|
||||
// name: "Product",
|
||||
// link: "/#product",
|
||||
// },
|
||||
// {
|
||||
// name: "Pricing",
|
||||
// link: "/#pricing",
|
||||
// },
|
||||
];
|
||||
const navItems = [
|
||||
{
|
||||
name: "Docs",
|
||||
link: "/docs",
|
||||
},
|
||||
// {
|
||||
// name: "Product",
|
||||
// link: "/#product",
|
||||
// },
|
||||
// {
|
||||
// name: "Pricing",
|
||||
// link: "/#pricing",
|
||||
// },
|
||||
];
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollY } = useScroll({
|
||||
target: ref,
|
||||
offset: ["start start", "end start"],
|
||||
});
|
||||
const [visible, setVisible] = useState<boolean>(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollY } = useScroll({
|
||||
target: ref,
|
||||
offset: ["start start", "end start"],
|
||||
});
|
||||
const [visible, setVisible] = useState<boolean>(false);
|
||||
|
||||
useMotionValueEvent(scrollY, "change", (latest) => {
|
||||
if (latest > 100) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
setVisible(false);
|
||||
}
|
||||
});
|
||||
useMotionValueEvent(scrollY, "change", (latest) => {
|
||||
if (latest > 100) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
setVisible(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div ref={ref} className="w-full fixed top-2 inset-x-0 z-50">
|
||||
<DesktopNav visible={visible} navItems={navItems} />
|
||||
<MobileNav visible={visible} navItems={navItems} />
|
||||
</motion.div>
|
||||
);
|
||||
return (
|
||||
<motion.div ref={ref} className="w-full fixed top-2 inset-x-0 z-50">
|
||||
<DesktopNav visible={visible} navItems={navItems} />
|
||||
<MobileNav visible={visible} navItems={navItems} />
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const DesktopNav = ({ navItems, visible }: NavbarProps) => {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect to the login page
|
||||
window.location.href = '/login';
|
||||
};
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect to the login page
|
||||
window.location.href = "/login";
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
animate={{
|
||||
backdropFilter: "blur(16px)",
|
||||
background: visible
|
||||
? "rgba(var(--background-rgb), 0.8)"
|
||||
: "rgba(var(--background-rgb), 0.6)",
|
||||
width: visible ? "38%" : "80%",
|
||||
height: visible ? "48px" : "64px",
|
||||
y: visible ? 8 : 0,
|
||||
}}
|
||||
initial={{
|
||||
width: "80%",
|
||||
height: "64px",
|
||||
background: "rgba(var(--background-rgb), 0.6)",
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={cn(
|
||||
"hidden lg:flex flex-row self-center items-center justify-between py-2 mx-auto px-6 rounded-full relative z-[60] backdrop-saturate-[1.8]",
|
||||
visible ? "border dark:border-white/10 border-gray-300/30" : "border-0"
|
||||
)}
|
||||
style={{
|
||||
"--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'",
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Logo className="h-8 w-8 rounded-md" />
|
||||
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<motion.div
|
||||
className="lg:flex flex-row items-center justify-end space-x-1 text-sm"
|
||||
animate={{
|
||||
scale: visible ? 0.9 : 1,
|
||||
}}
|
||||
>
|
||||
{navItems.map((navItem, idx) => (
|
||||
<motion.div
|
||||
key={`nav-item-${idx}`}
|
||||
onHoverStart={() => setHoveredIndex(idx)}
|
||||
className="relative"
|
||||
>
|
||||
<Link
|
||||
className="dark:text-white/90 text-gray-800 relative px-3 py-1.5 transition-colors"
|
||||
href={navItem.link}
|
||||
>
|
||||
<span className="relative z-10">{navItem.name}</span>
|
||||
{hoveredIndex === idx && (
|
||||
<motion.div
|
||||
layoutId="menu-hover"
|
||||
className="absolute inset-0 rounded-full dark:bg-gradient-to-r dark:from-white/10 dark:to-white/20 bg-gradient-to-r from-gray-200 to-gray-300"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1.1,
|
||||
background: "var(--tw-dark) ? radial-gradient(circle at center, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0.1) 50%, transparent 100%) : radial-gradient(circle at center, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.03) 50%, transparent 100%)",
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
bounce: 0.4,
|
||||
duration: 0.4,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
<ThemeTogglerComponent />
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{!visible && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 25,
|
||||
},
|
||||
}}
|
||||
exit={{
|
||||
scale: 0.8,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
className="hidden cursor-pointer md:flex items-center gap-2 rounded-full dark:bg-white/20 dark:hover:bg-white/30 dark:text-white bg-gray-100 hover:bg-gray-200 text-gray-800 border-0"
|
||||
>
|
||||
<IconUser className="h-4 w-4" />
|
||||
<span>Sign in</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
return (
|
||||
<motion.div
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
animate={{
|
||||
backdropFilter: "blur(16px)",
|
||||
background: visible
|
||||
? "rgba(var(--background-rgb), 0.8)"
|
||||
: "rgba(var(--background-rgb), 0.6)",
|
||||
width: visible ? "38%" : "80%",
|
||||
height: visible ? "48px" : "64px",
|
||||
y: visible ? 8 : 0,
|
||||
}}
|
||||
initial={{
|
||||
width: "80%",
|
||||
height: "64px",
|
||||
background: "rgba(var(--background-rgb), 0.6)",
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={cn(
|
||||
"hidden lg:flex flex-row self-center items-center justify-between py-2 mx-auto px-6 rounded-full relative z-[60] backdrop-saturate-[1.8]",
|
||||
visible ? "border dark:border-white/10 border-gray-300/30" : "border-0"
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Logo className="h-8 w-8 rounded-md" />
|
||||
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<motion.div
|
||||
className="lg:flex flex-row items-center justify-end space-x-1 text-sm"
|
||||
animate={{
|
||||
scale: visible ? 0.9 : 1,
|
||||
}}
|
||||
>
|
||||
{navItems.map((navItem, idx) => (
|
||||
<motion.div
|
||||
key={`nav-item-${navItem.name}`}
|
||||
onHoverStart={() => setHoveredIndex(idx)}
|
||||
className="relative"
|
||||
>
|
||||
<Link
|
||||
className="dark:text-white/90 text-gray-800 relative px-3 py-1.5 transition-colors"
|
||||
href={navItem.link}
|
||||
>
|
||||
<span className="relative z-10">{navItem.name}</span>
|
||||
{hoveredIndex === idx && (
|
||||
<motion.div
|
||||
layoutId="menu-hover"
|
||||
className="absolute inset-0 rounded-full dark:bg-gradient-to-r dark:from-white/10 dark:to-white/20 bg-gradient-to-r from-gray-200 to-gray-300"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1.1,
|
||||
background:
|
||||
"var(--tw-dark) ? radial-gradient(circle at center, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0.1) 50%, transparent 100%) : radial-gradient(circle at center, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.03) 50%, transparent 100%)",
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
bounce: 0.4,
|
||||
duration: 0.4,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
<ThemeTogglerComponent />
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{!visible && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 25,
|
||||
},
|
||||
}}
|
||||
exit={{
|
||||
scale: 0.8,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
className="hidden cursor-pointer md:flex items-center gap-2 rounded-full dark:bg-white/20 dark:hover:bg-white/30 dark:text-white bg-gray-100 hover:bg-gray-200 text-gray-800 border-0"
|
||||
>
|
||||
<IconUser className="h-4 w-4" />
|
||||
<span>Sign in</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileNav = ({ navItems, visible }: NavbarProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect to the login page
|
||||
window.location.href = "./login";
|
||||
};
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect to the login page
|
||||
window.location.href = "./login";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
animate={{
|
||||
backdropFilter: "blur(16px)",
|
||||
background: visible
|
||||
? "rgba(var(--background-rgb), 0.8)"
|
||||
: "rgba(var(--background-rgb), 0.6)",
|
||||
width: visible ? "80%" : "90%",
|
||||
y: visible ? 0 : 8,
|
||||
borderRadius: open ? "24px" : "full",
|
||||
padding: "8px 16px",
|
||||
}}
|
||||
initial={{
|
||||
width: "80%",
|
||||
background: "rgba(var(--background-rgb), 0.6)",
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={cn(
|
||||
"flex relative flex-col lg:hidden w-full justify-between items-center max-w-[calc(100vw-2rem)] mx-auto z-50 backdrop-saturate-[1.8] rounded-full",
|
||||
visible ? "border border-solid dark:border-white/40 border-gray-300/30" : "border-0"
|
||||
)}
|
||||
style={{
|
||||
"--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'",
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div className="flex flex-row justify-between items-center w-full">
|
||||
<Logo className="h-8 w-8 rounded-md" />
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeTogglerComponent />
|
||||
{open ? (
|
||||
<IconX className="dark:text-white/90 text-gray-800" onClick={() => setOpen(!open)} />
|
||||
) : (
|
||||
<IconMenu2
|
||||
className="dark:text-white/90 text-gray-800"
|
||||
onClick={() => setOpen(!open)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<motion.div
|
||||
animate={{
|
||||
backdropFilter: "blur(16px)",
|
||||
background: visible
|
||||
? "rgba(var(--background-rgb), 0.8)"
|
||||
: "rgba(var(--background-rgb), 0.6)",
|
||||
width: visible ? "80%" : "90%",
|
||||
y: visible ? 0 : 8,
|
||||
borderRadius: open ? "24px" : "full",
|
||||
padding: "8px 16px",
|
||||
}}
|
||||
initial={{
|
||||
width: "80%",
|
||||
background: "rgba(var(--background-rgb), 0.6)",
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={cn(
|
||||
"flex relative flex-col lg:hidden w-full justify-between items-center max-w-[calc(100vw-2rem)] mx-auto z-50 backdrop-saturate-[1.8] rounded-full",
|
||||
visible ? "border border-solid dark:border-white/40 border-gray-300/30" : "border-0"
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="flex flex-row justify-between items-center w-full">
|
||||
<Logo className="h-8 w-8 rounded-md" />
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeTogglerComponent />
|
||||
{open ? (
|
||||
<IconX className="dark:text-white/90 text-gray-800" onClick={() => setOpen(!open)} />
|
||||
) : (
|
||||
<IconMenu2
|
||||
className="dark:text-white/90 text-gray-800"
|
||||
onClick={() => setOpen(!open)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className="flex rounded-3xl absolute top-16 dark:bg-black/80 bg-white/90 backdrop-blur-xl backdrop-saturate-[1.8] inset-x-0 z-50 flex-col items-start justify-start gap-4 w-full px-6 py-8"
|
||||
>
|
||||
{navItems.map(
|
||||
(navItem: { link: string; name: string }, idx: number) => (
|
||||
<Link
|
||||
key={`link=${idx}`}
|
||||
href={navItem.link}
|
||||
onClick={() => setOpen(false)}
|
||||
className="relative dark:text-white/90 text-gray-800 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<motion.span className="block">{navItem.name}</motion.span>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
className="flex cursor-pointer items-center gap-2 mt-4 w-full justify-center rounded-full dark:bg-white/20 dark:hover:bg-white/30 dark:text-white bg-gray-100 hover:bg-gray-200 text-gray-800 border-0"
|
||||
>
|
||||
<IconUser className="h-4 w-4" />
|
||||
<span>Sign in</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className="flex rounded-3xl absolute top-16 dark:bg-black/80 bg-white/90 backdrop-blur-xl backdrop-saturate-[1.8] inset-x-0 z-50 flex-col items-start justify-start gap-4 w-full px-6 py-8"
|
||||
>
|
||||
{navItems.map((navItem: { link: string; name: string }) => (
|
||||
<Link
|
||||
key={`link-${navItem.name}`}
|
||||
href={navItem.link}
|
||||
onClick={() => setOpen(false)}
|
||||
className="relative dark:text-white/90 text-gray-800 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<motion.span className="block">{navItem.name}</motion.span>
|
||||
</Link>
|
||||
))}
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
className="flex cursor-pointer items-center gap-2 mt-4 w-full justify-center rounded-full dark:bg-white/20 dark:hover:bg-white/30 dark:text-white bg-gray-100 hover:bg-gray-200 text-gray-800 border-0"
|
||||
>
|
||||
<IconUser className="h-4 w-4" />
|
||||
<span>Sign in</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,55 +1,55 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface TokenHandlerProps {
|
||||
redirectPath?: string; // Path to redirect after storing token
|
||||
tokenParamName?: string; // Name of the URL parameter containing the token
|
||||
storageKey?: string; // Key to use when storing in localStorage
|
||||
redirectPath?: string; // Path to redirect after storing token
|
||||
tokenParamName?: string; // Name of the URL parameter containing the token
|
||||
storageKey?: string; // Key to use when storing in localStorage
|
||||
}
|
||||
|
||||
/**
|
||||
* Client component that extracts a token from URL parameters and stores it in localStorage
|
||||
*
|
||||
*
|
||||
* @param redirectPath - Path to redirect after storing token (default: '/')
|
||||
* @param tokenParamName - Name of the URL parameter containing the token (default: 'token')
|
||||
* @param storageKey - Key to use when storing in localStorage (default: 'auth_token')
|
||||
*/
|
||||
const TokenHandler = ({
|
||||
redirectPath = '/',
|
||||
tokenParamName = 'token',
|
||||
storageKey = 'surfsense_bearer_token'
|
||||
redirectPath = "/",
|
||||
tokenParamName = "token",
|
||||
storageKey = "surfsense_bearer_token",
|
||||
}: TokenHandlerProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
// Only run on client-side
|
||||
if (typeof window === 'undefined') return;
|
||||
useEffect(() => {
|
||||
// Only run on client-side
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Get token from URL parameters
|
||||
const token = searchParams.get(tokenParamName);
|
||||
// Get token from URL parameters
|
||||
const token = searchParams.get(tokenParamName);
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
// Store token in localStorage
|
||||
localStorage.setItem(storageKey, token);
|
||||
// console.log(`Token stored in localStorage with key: ${storageKey}`);
|
||||
|
||||
// Redirect to specified path
|
||||
router.push(redirectPath);
|
||||
} catch (error) {
|
||||
console.error('Error storing token in localStorage:', error);
|
||||
}
|
||||
}
|
||||
}, [searchParams, tokenParamName, storageKey, redirectPath, router]);
|
||||
if (token) {
|
||||
try {
|
||||
// Store token in localStorage
|
||||
localStorage.setItem(storageKey, token);
|
||||
// console.log(`Token stored in localStorage with key: ${storageKey}`);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
<p className="text-gray-500">Processing authentication...</p>
|
||||
</div>
|
||||
);
|
||||
// Redirect to specified path
|
||||
router.push(redirectPath);
|
||||
} catch (error) {
|
||||
console.error("Error storing token in localStorage:", error);
|
||||
}
|
||||
}
|
||||
}, [searchParams, tokenParamName, storageKey, redirectPath, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
<p className="text-gray-500">Processing authentication...</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TokenHandler;
|
||||
export default TokenHandler;
|
||||
|
|
|
|||
|
|
@ -1,101 +1,80 @@
|
|||
"use client"
|
||||
"use client";
|
||||
|
||||
import { BadgeCheck, LogOut, Settings } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
BadgeCheck,
|
||||
ChevronsUpDown,
|
||||
LogOut,
|
||||
Settings,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/ui/avatar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useRouter, useParams } from "next/navigation"
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export function UserDropdown({
|
||||
user,
|
||||
user,
|
||||
}: {
|
||||
user: {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
}
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = () => {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('surfsense_bearer_token');
|
||||
router.push('/');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during logout:', error);
|
||||
// Optionally, provide user feedback
|
||||
if (typeof window !== 'undefined') {
|
||||
alert('Logout failed. Please try again.');
|
||||
router.push('/');
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleLogout = () => {
|
||||
try {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("surfsense_bearer_token");
|
||||
router.push("/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during logout:", error);
|
||||
// Optionally, provide user feedback
|
||||
if (typeof window !== "undefined") {
|
||||
alert("Logout failed. Please try again.");
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="relative h-10 w-10 rounded-full"
|
||||
>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback>{user.name.charAt(0)?.toUpperCase() || '?'}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-56"
|
||||
align="end"
|
||||
forceMount
|
||||
>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{user.name}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuItem onClick={() => router.push(`/dashboard/api-key`)}>
|
||||
<BadgeCheck className="mr-2 h-4 w-4" />
|
||||
API Key
|
||||
</DropdownMenuItem>
|
||||
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => router.push(`/settings`)}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback>{user.name.charAt(0)?.toUpperCase() || "?"}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{user.name}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => router.push(`/dashboard/api-key`)}>
|
||||
<BadgeCheck className="mr-2 h-4 w-4" />
|
||||
API Key
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => router.push(`/settings`)}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,160 +1,152 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Manrope } from "next/font/google";
|
||||
import React, {
|
||||
useRef,
|
||||
useEffect,
|
||||
useReducer,
|
||||
useMemo
|
||||
} from "react";
|
||||
import { RoughNotation, RoughNotationGroup } from "react-rough-notation";
|
||||
import { useInView } from "framer-motion";
|
||||
import { Manrope } from "next/font/google";
|
||||
import { useEffect, useMemo, useReducer, useRef } from "react";
|
||||
import { RoughNotation, RoughNotationGroup } from "react-rough-notation";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Font configuration - could be moved to a global font config file
|
||||
const manrope = Manrope({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "700"],
|
||||
display: "swap", // Optimize font loading
|
||||
variable: "--font-manrope"
|
||||
const manrope = Manrope({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "700"],
|
||||
display: "swap", // Optimize font loading
|
||||
variable: "--font-manrope",
|
||||
});
|
||||
|
||||
// Constants for timing - makes it easier to adjust and more maintainable
|
||||
const TIMING = {
|
||||
SIDEBAR_TRANSITION: 300, // Wait for sidebar transition + buffer
|
||||
LAYOUT_SETTLE: 100, // Small delay to ensure layout is fully settled
|
||||
SIDEBAR_TRANSITION: 300, // Wait for sidebar transition + buffer
|
||||
LAYOUT_SETTLE: 100, // Small delay to ensure layout is fully settled
|
||||
} as const;
|
||||
|
||||
// Animation configuration
|
||||
const ANIMATION_CONFIG = {
|
||||
HIGHLIGHT: {
|
||||
type: "highlight" as const,
|
||||
animationDuration: 2000,
|
||||
iterations: 3,
|
||||
color: "#3b82f680",
|
||||
multiline: true,
|
||||
},
|
||||
UNDERLINE: {
|
||||
type: "underline" as const,
|
||||
animationDuration: 2000,
|
||||
iterations: 3,
|
||||
color: "#10b981",
|
||||
},
|
||||
HIGHLIGHT: {
|
||||
type: "highlight" as const,
|
||||
animationDuration: 2000,
|
||||
iterations: 3,
|
||||
color: "#3b82f680",
|
||||
multiline: true,
|
||||
},
|
||||
UNDERLINE: {
|
||||
type: "underline" as const,
|
||||
animationDuration: 2000,
|
||||
iterations: 3,
|
||||
color: "#10b981",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// State management with useReducer for better organization
|
||||
interface HighlightState {
|
||||
shouldShowHighlight: boolean;
|
||||
layoutStable: boolean;
|
||||
shouldShowHighlight: boolean;
|
||||
layoutStable: boolean;
|
||||
}
|
||||
|
||||
type HighlightAction =
|
||||
| { type: "SIDEBAR_CHANGED" }
|
||||
| { type: "LAYOUT_STABILIZED" }
|
||||
| { type: "SHOW_HIGHLIGHT" }
|
||||
| { type: "HIDE_HIGHLIGHT" };
|
||||
type HighlightAction =
|
||||
| { type: "SIDEBAR_CHANGED" }
|
||||
| { type: "LAYOUT_STABILIZED" }
|
||||
| { type: "SHOW_HIGHLIGHT" }
|
||||
| { type: "HIDE_HIGHLIGHT" };
|
||||
|
||||
const highlightReducer = (
|
||||
state: HighlightState,
|
||||
action: HighlightAction
|
||||
): HighlightState => {
|
||||
switch (action.type) {
|
||||
case "SIDEBAR_CHANGED":
|
||||
return {
|
||||
shouldShowHighlight: false,
|
||||
layoutStable: false,
|
||||
};
|
||||
case "LAYOUT_STABILIZED":
|
||||
return {
|
||||
...state,
|
||||
layoutStable: true,
|
||||
};
|
||||
case "SHOW_HIGHLIGHT":
|
||||
return {
|
||||
...state,
|
||||
shouldShowHighlight: true,
|
||||
};
|
||||
case "HIDE_HIGHLIGHT":
|
||||
return {
|
||||
...state,
|
||||
shouldShowHighlight: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
const highlightReducer = (state: HighlightState, action: HighlightAction): HighlightState => {
|
||||
switch (action.type) {
|
||||
case "SIDEBAR_CHANGED":
|
||||
return {
|
||||
shouldShowHighlight: false,
|
||||
layoutStable: false,
|
||||
};
|
||||
case "LAYOUT_STABILIZED":
|
||||
return {
|
||||
...state,
|
||||
layoutStable: true,
|
||||
};
|
||||
case "SHOW_HIGHLIGHT":
|
||||
return {
|
||||
...state,
|
||||
shouldShowHighlight: true,
|
||||
};
|
||||
case "HIDE_HIGHLIGHT":
|
||||
return {
|
||||
...state,
|
||||
shouldShowHighlight: false,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const initialState: HighlightState = {
|
||||
shouldShowHighlight: false,
|
||||
layoutStable: true,
|
||||
shouldShowHighlight: false,
|
||||
layoutStable: true,
|
||||
};
|
||||
|
||||
export function AnimatedEmptyState() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(ref);
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const [{ shouldShowHighlight, layoutStable }, dispatch] = useReducer(
|
||||
highlightReducer,
|
||||
initialState
|
||||
);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(ref);
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const [{ shouldShowHighlight, layoutStable }, dispatch] = useReducer(
|
||||
highlightReducer,
|
||||
initialState
|
||||
);
|
||||
|
||||
// Memoize class names to prevent unnecessary recalculations
|
||||
const headingClassName = useMemo(() => cn(
|
||||
"text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-neutral-900 dark:text-neutral-50 mb-6",
|
||||
manrope.className,
|
||||
), []);
|
||||
// Memoize class names to prevent unnecessary recalculations
|
||||
const headingClassName = useMemo(
|
||||
() =>
|
||||
cn(
|
||||
"text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-neutral-900 dark:text-neutral-50 mb-6",
|
||||
manrope.className
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const paragraphClassName = useMemo(() =>
|
||||
"text-lg sm:text-xl text-neutral-600 dark:text-neutral-300 mb-8 max-w-2xl mx-auto",
|
||||
[]);
|
||||
const paragraphClassName = useMemo(
|
||||
() => "text-lg sm:text-xl text-neutral-600 dark:text-neutral-300 mb-8 max-w-2xl mx-auto",
|
||||
[]
|
||||
);
|
||||
|
||||
// Handle sidebar state changes
|
||||
useEffect(() => {
|
||||
dispatch({ type: "SIDEBAR_CHANGED" });
|
||||
// Handle sidebar state changes
|
||||
useEffect(() => {
|
||||
dispatch({ type: "SIDEBAR_CHANGED" });
|
||||
|
||||
const stabilizeTimer = setTimeout(() => {
|
||||
dispatch({ type: "LAYOUT_STABILIZED" });
|
||||
}, TIMING.SIDEBAR_TRANSITION);
|
||||
const stabilizeTimer = setTimeout(() => {
|
||||
dispatch({ type: "LAYOUT_STABILIZED" });
|
||||
}, TIMING.SIDEBAR_TRANSITION);
|
||||
|
||||
return () => clearTimeout(stabilizeTimer);
|
||||
}, [sidebarState]);
|
||||
return () => clearTimeout(stabilizeTimer);
|
||||
}, []);
|
||||
|
||||
// Handle highlight visibility based on layout stability and viewport visibility
|
||||
useEffect(() => {
|
||||
if (!layoutStable || !isInView) {
|
||||
dispatch({ type: "HIDE_HIGHLIGHT" });
|
||||
return;
|
||||
}
|
||||
// Handle highlight visibility based on layout stability and viewport visibility
|
||||
useEffect(() => {
|
||||
if (!layoutStable || !isInView) {
|
||||
dispatch({ type: "HIDE_HIGHLIGHT" });
|
||||
return;
|
||||
}
|
||||
|
||||
const showTimer = setTimeout(() => {
|
||||
dispatch({ type: "SHOW_HIGHLIGHT" });
|
||||
}, TIMING.LAYOUT_SETTLE);
|
||||
const showTimer = setTimeout(() => {
|
||||
dispatch({ type: "SHOW_HIGHLIGHT" });
|
||||
}, TIMING.LAYOUT_SETTLE);
|
||||
|
||||
return () => clearTimeout(showTimer);
|
||||
}, [layoutStable, isInView]);
|
||||
return () => clearTimeout(showTimer);
|
||||
}, [layoutStable, isInView]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex-1 flex items-center justify-center w-full min-h-[400px]"
|
||||
>
|
||||
<div className="max-w-4xl mx-auto px-4 py-10 text-center">
|
||||
<RoughNotationGroup show={shouldShowHighlight}>
|
||||
<h1 className={headingClassName}>
|
||||
<RoughNotation {...ANIMATION_CONFIG.HIGHLIGHT}>
|
||||
<span>SurfSense</span>
|
||||
</RoughNotation>
|
||||
</h1>
|
||||
return (
|
||||
<div ref={ref} className="flex-1 flex items-center justify-center w-full min-h-[400px]">
|
||||
<div className="max-w-4xl mx-auto px-4 py-10 text-center">
|
||||
<RoughNotationGroup show={shouldShowHighlight}>
|
||||
<h1 className={headingClassName}>
|
||||
<RoughNotation {...ANIMATION_CONFIG.HIGHLIGHT}>
|
||||
<span>SurfSense</span>
|
||||
</RoughNotation>
|
||||
</h1>
|
||||
|
||||
<p className={paragraphClassName}>
|
||||
<RoughNotation {...ANIMATION_CONFIG.UNDERLINE}>
|
||||
Let's Start Surfing
|
||||
</RoughNotation>{" "}
|
||||
through your knowledge base.
|
||||
</p>
|
||||
</RoughNotationGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<p className={paragraphClassName}>
|
||||
<RoughNotation {...ANIMATION_CONFIG.UNDERLINE}>Let's Start Surfing</RoughNotation>{" "}
|
||||
through your knowledge base.
|
||||
</p>
|
||||
</RoughNotationGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
export const CitationDisplay: React.FC<{ index: number; node: any }> = ({
|
||||
index,
|
||||
node,
|
||||
}) => {
|
||||
export const CitationDisplay: React.FC<{ index: number; node: any }> = ({ index, node }) => {
|
||||
const truncateText = (text: string, maxLength: number = 200) => {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + "...";
|
||||
return `${text.substring(0, maxLength)}...`;
|
||||
};
|
||||
|
||||
const handleUrlClick = (e: React.MouseEvent, url: string) => {
|
||||
|
|
@ -33,13 +27,15 @@ export const CitationDisplay: React.FC<{ index: number; node: any }> = ({
|
|||
<PopoverContent className="w-80 p-4 space-y-3 relative" align="start">
|
||||
{/* External Link Button - Top Right */}
|
||||
{node?.url && (
|
||||
<button
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={(e) => handleUrlClick(e, node.url)}
|
||||
className="absolute top-3 right-3 inline-flex items-center justify-center w-6 h-6 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
|
||||
title="Open in new tab"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Heading */}
|
||||
|
|
|
|||
|
|
@ -1,45 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { getAnnotationData, type Message, useChatUI } from "@llamaindex/chat-ui";
|
||||
import { SuggestedQuestions } from "@llamaindex/chat-ui/widgets";
|
||||
import { getAnnotationData, Message, useChatUI } from "@llamaindex/chat-ui";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
|
||||
export const ChatFurtherQuestions: React.FC<{ message: Message }> = ({
|
||||
message,
|
||||
}) => {
|
||||
const annotations: string[][] = getAnnotationData(
|
||||
message,
|
||||
"FURTHER_QUESTIONS",
|
||||
);
|
||||
const { append, requestData } = useChatUI();
|
||||
export const ChatFurtherQuestions: React.FC<{ message: Message }> = ({ message }) => {
|
||||
const annotations: string[][] = getAnnotationData(message, "FURTHER_QUESTIONS");
|
||||
const { append, requestData } = useChatUI();
|
||||
|
||||
if (annotations.length !== 1 || annotations[0].length === 0) {
|
||||
return <></>;
|
||||
}
|
||||
if (annotations.length !== 1 || annotations[0].length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full border rounded-md bg-card shadow-sm"
|
||||
>
|
||||
<AccordionItem value="suggested-questions" className="border-0">
|
||||
<AccordionTrigger className="px-4 py-3 text-sm font-medium text-foreground transition-colors">
|
||||
Further Suggested Questions
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-4 pt-0">
|
||||
<SuggestedQuestions
|
||||
questions={annotations[0]}
|
||||
append={append}
|
||||
requestData={requestData}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
return (
|
||||
<Accordion type="single" collapsible className="w-full border rounded-md bg-card shadow-sm">
|
||||
<AccordionItem value="suggested-questions" className="border-0">
|
||||
<AccordionTrigger className="px-4 py-3 text-sm font-medium text-foreground transition-colors">
|
||||
Further Suggested Questions
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-4 pt-0">
|
||||
<SuggestedQuestions
|
||||
questions={annotations[0]}
|
||||
append={append}
|
||||
requestData={requestData}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import { ChatInput } from "@llamaindex/chat-ui";
|
||||
import { FolderOpen, Check, Zap, Brain } from "lucide-react";
|
||||
import { Brain, Check, FolderOpen, Zap } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import React, { Suspense, useCallback, useState } from "react";
|
||||
import type { ResearchMode } from "@/components/chat";
|
||||
import {
|
||||
ConnectorButton as ConnectorButtonComponent,
|
||||
getConnectorIcon,
|
||||
} from "@/components/chat/ConnectorComponents";
|
||||
import { DocumentsDataTable } from "@/components/chat/DocumentsDataTable";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -18,19 +27,9 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Suspense, useState, useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useDocuments, Document } from "@/hooks/use-documents";
|
||||
import { DocumentsDataTable } from "@/components/chat/DocumentsDataTable";
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
import {
|
||||
getConnectorIcon,
|
||||
ConnectorButton as ConnectorButtonComponent,
|
||||
} from "@/components/chat/ConnectorComponents";
|
||||
import { ResearchMode } from "@/components/chat";
|
||||
import { type Document, useDocuments } from "@/hooks/use-documents";
|
||||
import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||
import React from "react";
|
||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||
|
||||
const DocumentSelector = React.memo(
|
||||
({
|
||||
|
|
@ -45,7 +44,7 @@ const DocumentSelector = React.memo(
|
|||
|
||||
const { documents, loading, isLoaded, fetchDocuments } = useDocuments(
|
||||
Number(search_space_id),
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
|
|
@ -55,24 +54,21 @@ const DocumentSelector = React.memo(
|
|||
fetchDocuments();
|
||||
}
|
||||
},
|
||||
[fetchDocuments, isLoaded],
|
||||
[fetchDocuments, isLoaded]
|
||||
);
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
(documents: Document[]) => {
|
||||
onSelectionChange?.(documents);
|
||||
},
|
||||
[onSelectionChange],
|
||||
[onSelectionChange]
|
||||
);
|
||||
|
||||
const handleDone = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
const selectedCount = React.useMemo(
|
||||
() => selectedDocuments.length,
|
||||
[selectedDocuments.length],
|
||||
);
|
||||
const selectedCount = React.useMemo(() => selectedDocuments.length, [selectedDocuments.length]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
|
|
@ -90,9 +86,7 @@ const DocumentSelector = React.memo(
|
|||
<DialogContent className="max-w-[95vw] md:max-w-5xl h-[90vh] md:h-[85vh] p-0 flex flex-col">
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-4 md:px-6 py-4 border-b flex-shrink-0">
|
||||
<DialogTitle className="text-lg md:text-xl">
|
||||
Select Documents
|
||||
</DialogTitle>
|
||||
<DialogTitle className="text-lg md:text-xl">Select Documents</DialogTitle>
|
||||
<DialogDescription className="mt-1 text-sm">
|
||||
Choose documents to include in your research context
|
||||
</DialogDescription>
|
||||
|
|
@ -103,9 +97,7 @@ const DocumentSelector = React.memo(
|
|||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full mx-auto" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading documents...
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Loading documents...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : isLoaded ? (
|
||||
|
|
@ -121,7 +113,7 @@ const DocumentSelector = React.memo(
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
DocumentSelector.displayName = "DocumentSelector";
|
||||
|
|
@ -146,7 +138,7 @@ const ConnectorSelector = React.memo(
|
|||
fetchConnectors();
|
||||
}
|
||||
},
|
||||
[fetchConnectors, isLoaded],
|
||||
[fetchConnectors, isLoaded]
|
||||
);
|
||||
|
||||
const handleConnectorToggle = useCallback(
|
||||
|
|
@ -157,7 +149,7 @@ const ConnectorSelector = React.memo(
|
|||
: [...selectedConnectors, connectorType];
|
||||
onSelectionChange?.(newSelection);
|
||||
},
|
||||
[selectedConnectors, onSelectionChange],
|
||||
[selectedConnectors, onSelectionChange]
|
||||
);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
|
|
@ -195,26 +187,17 @@ const ConnectorSelector = React.memo(
|
|||
const isSelected = selectedConnectors.includes(connector.type);
|
||||
|
||||
return (
|
||||
<div
|
||||
<Button
|
||||
key={connector.id}
|
||||
className={`flex items-center gap-2 p-2 rounded-md border cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted"
|
||||
}`}
|
||||
className={`flex items-center gap-2 p-2 rounded-md border cursor-pointer transition-colors`}
|
||||
onClick={() => handleConnectorToggle(connector.type)}
|
||||
role="checkbox"
|
||||
aria-checked={isSelected}
|
||||
tabIndex={0}
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
<div className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded-full bg-muted">
|
||||
{getConnectorIcon(connector.type)}
|
||||
</div>
|
||||
<span className="flex-1 text-sm font-medium">
|
||||
{connector.name}
|
||||
</span>
|
||||
{isSelected && <Check className="h-4 w-4 text-primary" />}
|
||||
</div>
|
||||
{getConnectorIcon(connector.type)}
|
||||
<span className="flex-1 text-sm font-medium">{connector.name}</span>
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
|
@ -231,7 +214,7 @@ const ConnectorSelector = React.memo(
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
ConnectorSelector.displayName = "ConnectorSelector";
|
||||
|
|
@ -254,9 +237,7 @@ const SearchModeSelector = React.memo(
|
|||
|
||||
return (
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<span className="text-xs text-muted-foreground hidden sm:block">
|
||||
Scope:
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground hidden sm:block">Scope:</span>
|
||||
<div className="flex rounded-md border border-border overflow-hidden">
|
||||
<Button
|
||||
variant={searchMode === "DOCUMENTS" ? "default" : "ghost"}
|
||||
|
|
@ -278,7 +259,7 @@ const SearchModeSelector = React.memo(
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
SearchModeSelector.displayName = "SearchModeSelector";
|
||||
|
|
@ -295,7 +276,7 @@ const ResearchModeSelector = React.memo(
|
|||
(value: string) => {
|
||||
onResearchModeChange?.(value as ResearchMode);
|
||||
},
|
||||
[onResearchModeChange],
|
||||
[onResearchModeChange]
|
||||
);
|
||||
|
||||
// Memoize mode options to prevent recreation
|
||||
|
|
@ -318,14 +299,12 @@ const ResearchModeSelector = React.memo(
|
|||
shortLabel: "Deeper",
|
||||
},
|
||||
],
|
||||
[],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<span className="text-xs text-muted-foreground hidden sm:block">
|
||||
Mode:
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground hidden sm:block">Mode:</span>
|
||||
<Select value={researchMode} onValueChange={handleValueChange}>
|
||||
<SelectTrigger className="w-auto min-w-[80px] sm:min-w-[120px] h-8 text-xs border-border bg-background hover:bg-muted/50 transition-colors duration-200 focus:ring-2 focus:ring-primary/20">
|
||||
<SelectValue placeholder="Mode" className="text-xs" />
|
||||
|
|
@ -348,27 +327,21 @@ const ResearchModeSelector = React.memo(
|
|||
</Select>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
ResearchModeSelector.displayName = "ResearchModeSelector";
|
||||
|
||||
const LLMSelector = React.memo(() => {
|
||||
const { llmConfigs, loading: llmLoading, error } = useLLMConfigs();
|
||||
const {
|
||||
preferences,
|
||||
updatePreferences,
|
||||
loading: preferencesLoading,
|
||||
} = useLLMPreferences();
|
||||
const { preferences, updatePreferences, loading: preferencesLoading } = useLLMPreferences();
|
||||
|
||||
const isLoading = llmLoading || preferencesLoading;
|
||||
|
||||
// Memoize the selected config to avoid repeated lookups
|
||||
const selectedConfig = React.useMemo(() => {
|
||||
if (!preferences.fast_llm_id || !llmConfigs.length) return null;
|
||||
return (
|
||||
llmConfigs.find((config) => config.id === preferences.fast_llm_id) || null
|
||||
);
|
||||
return llmConfigs.find((config) => config.id === preferences.fast_llm_id) || null;
|
||||
}, [preferences.fast_llm_id, llmConfigs]);
|
||||
|
||||
// Memoize the display value for the trigger
|
||||
|
|
@ -390,7 +363,7 @@ const LLMSelector = React.memo(() => {
|
|||
const llmId = value ? parseInt(value, 10) : undefined;
|
||||
updatePreferences({ fast_llm_id: llmId });
|
||||
},
|
||||
[updatePreferences],
|
||||
[updatePreferences]
|
||||
);
|
||||
|
||||
// Loading skeleton
|
||||
|
|
@ -432,9 +405,7 @@ const LLMSelector = React.memo(() => {
|
|||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Zap className="h-3 w-3 text-primary flex-shrink-0" />
|
||||
<SelectValue placeholder="Fast LLM" className="text-xs">
|
||||
{displayValue || (
|
||||
<span className="text-muted-foreground">Select LLM</span>
|
||||
)}
|
||||
{displayValue || <span className="text-muted-foreground">Select LLM</span>}
|
||||
</SelectValue>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
|
|
@ -452,9 +423,7 @@ const LLMSelector = React.memo(() => {
|
|||
<div className="mx-auto w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3">
|
||||
<Brain className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h4 className="text-sm font-medium mb-1">
|
||||
No LLM configurations
|
||||
</h4>
|
||||
<h4 className="text-sm font-medium mb-1">No LLM configurations</h4>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Configure AI models to get started
|
||||
</p>
|
||||
|
|
@ -482,13 +451,8 @@ const LLMSelector = React.memo(() => {
|
|||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{config.name}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1.5 py-0.5 flex-shrink-0"
|
||||
>
|
||||
<span className="font-medium text-sm truncate">{config.name}</span>
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0.5 flex-shrink-0">
|
||||
{config.provider}
|
||||
</Badge>
|
||||
</div>
|
||||
|
|
@ -537,10 +501,8 @@ const CustomChatInputOptions = React.memo(
|
|||
}) => {
|
||||
// Memoize the loading fallback to prevent recreation
|
||||
const loadingFallback = React.useMemo(
|
||||
() => (
|
||||
<div className="h-8 min-w-[100px] animate-pulse bg-muted rounded-md" />
|
||||
),
|
||||
[],
|
||||
() => <div className="h-8 min-w-[100px] animate-pulse bg-muted rounded-md" />,
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -557,10 +519,7 @@ const CustomChatInputOptions = React.memo(
|
|||
selectedConnectors={selectedConnectors}
|
||||
/>
|
||||
</Suspense>
|
||||
<SearchModeSelector
|
||||
searchMode={searchMode}
|
||||
onSearchModeChange={onSearchModeChange}
|
||||
/>
|
||||
<SearchModeSelector searchMode={searchMode} onSearchModeChange={onSearchModeChange} />
|
||||
<ResearchModeSelector
|
||||
researchMode={researchMode}
|
||||
onResearchModeChange={onResearchModeChange}
|
||||
|
|
@ -568,7 +527,7 @@ const CustomChatInputOptions = React.memo(
|
|||
<LLMSelector />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
CustomChatInputOptions.displayName = "CustomChatInputOptions";
|
||||
|
|
@ -611,7 +570,7 @@ export const ChatInputUI = React.memo(
|
|||
/>
|
||||
</ChatInput>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
ChatInputUI.displayName = "ChatInputUI";
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
ChatSection as LlamaIndexChatSection,
|
||||
ChatHandler,
|
||||
} from "@llamaindex/chat-ui";
|
||||
import { Document } from "@/hooks/use-documents";
|
||||
import { type ChatHandler, ChatSection as LlamaIndexChatSection } from "@llamaindex/chat-ui";
|
||||
import type { ResearchMode } from "@/components/chat";
|
||||
import { ChatInputUI } from "@/components/chat/ChatInputGroup";
|
||||
import { ResearchMode } from "@/components/chat";
|
||||
import { ChatMessagesUI } from "@/components/chat/ChatMessages";
|
||||
import type { Document } from "@/hooks/use-documents";
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
handler: ChatHandler;
|
||||
|
|
|
|||
|
|
@ -1,87 +1,73 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
ChatMessage as LlamaIndexChatMessage,
|
||||
ChatMessages as LlamaIndexChatMessages,
|
||||
Message,
|
||||
useChatUI,
|
||||
ChatMessage as LlamaIndexChatMessage,
|
||||
ChatMessages as LlamaIndexChatMessages,
|
||||
type Message,
|
||||
useChatUI,
|
||||
} from "@llamaindex/chat-ui";
|
||||
import TerminalDisplay from "@/components/chat/ChatTerminal";
|
||||
import ChatSourcesDisplay from "@/components/chat/ChatSources";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AnimatedEmptyState } from "@/components/chat/AnimatedEmptyState";
|
||||
import { CitationDisplay } from "@/components/chat/ChatCitation";
|
||||
import { ChatFurtherQuestions } from "@/components/chat/ChatFurtherQuestions";
|
||||
import { AnimatedEmptyState } from "@/components/chat/AnimatedEmptyState";
|
||||
import ChatSourcesDisplay from "@/components/chat/ChatSources";
|
||||
import TerminalDisplay from "@/components/chat/ChatTerminal";
|
||||
import { languageRenderers } from "@/components/chat/CodeBlock";
|
||||
|
||||
|
||||
|
||||
export function ChatMessagesUI() {
|
||||
const { messages } = useChatUI();
|
||||
const { messages } = useChatUI();
|
||||
|
||||
return (
|
||||
<LlamaIndexChatMessages className="flex-1">
|
||||
<LlamaIndexChatMessages.Empty>
|
||||
<AnimatedEmptyState />
|
||||
</LlamaIndexChatMessages.Empty>
|
||||
<LlamaIndexChatMessages.List className="p-4">
|
||||
{messages.map((message, index) => (
|
||||
<ChatMessageUI
|
||||
key={`Message-${index}`}
|
||||
message={message}
|
||||
isLast={index === messages.length - 1}
|
||||
/>
|
||||
))}
|
||||
</LlamaIndexChatMessages.List>
|
||||
<LlamaIndexChatMessages.Loading />
|
||||
</LlamaIndexChatMessages>
|
||||
);
|
||||
return (
|
||||
<LlamaIndexChatMessages className="flex-1">
|
||||
<LlamaIndexChatMessages.Empty>
|
||||
<AnimatedEmptyState />
|
||||
</LlamaIndexChatMessages.Empty>
|
||||
<LlamaIndexChatMessages.List className="p-4">
|
||||
{messages.map((message, index) => (
|
||||
<ChatMessageUI
|
||||
key={`Message-${index}`}
|
||||
message={message}
|
||||
isLast={index === messages.length - 1}
|
||||
/>
|
||||
))}
|
||||
</LlamaIndexChatMessages.List>
|
||||
<LlamaIndexChatMessages.Loading />
|
||||
</LlamaIndexChatMessages>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessageUI({
|
||||
message,
|
||||
isLast,
|
||||
}: {
|
||||
message: Message;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
const bottomRef = React.useRef<HTMLDivElement>(null);
|
||||
function ChatMessageUI({ message, isLast }: { message: Message; isLast: boolean }) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLast && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [message]);
|
||||
useEffect(() => {
|
||||
if (isLast && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [isLast]);
|
||||
|
||||
return (
|
||||
<LlamaIndexChatMessage
|
||||
message={message}
|
||||
isLast={isLast}
|
||||
className="flex flex-col "
|
||||
>
|
||||
{message.role === "assistant" ? (
|
||||
<div className="flex-1 flex flex-col space-y-4">
|
||||
<TerminalDisplay message={message} open={isLast} />
|
||||
<ChatSourcesDisplay message={message} />
|
||||
<LlamaIndexChatMessage.Content className="flex-1">
|
||||
<LlamaIndexChatMessage.Content.Markdown
|
||||
citationComponent={CitationDisplay}
|
||||
languageRenderers={languageRenderers}
|
||||
/>
|
||||
</LlamaIndexChatMessage.Content>
|
||||
<div ref={bottomRef} />
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{isLast && <ChatFurtherQuestions message={message} />}
|
||||
<LlamaIndexChatMessage.Actions className="flex-1 flex-col" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<LlamaIndexChatMessage.Content className="flex-1">
|
||||
<LlamaIndexChatMessage.Content.Markdown
|
||||
languageRenderers={languageRenderers}
|
||||
/>
|
||||
</LlamaIndexChatMessage.Content>
|
||||
)}
|
||||
</LlamaIndexChatMessage>
|
||||
);
|
||||
return (
|
||||
<LlamaIndexChatMessage message={message} isLast={isLast} className="flex flex-col ">
|
||||
{message.role === "assistant" ? (
|
||||
<div className="flex-1 flex flex-col space-y-4">
|
||||
<TerminalDisplay message={message} open={isLast} />
|
||||
<ChatSourcesDisplay message={message} />
|
||||
<LlamaIndexChatMessage.Content className="flex-1">
|
||||
<LlamaIndexChatMessage.Content.Markdown
|
||||
citationComponent={CitationDisplay}
|
||||
languageRenderers={languageRenderers}
|
||||
/>
|
||||
</LlamaIndexChatMessage.Content>
|
||||
<div ref={bottomRef} />
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{isLast && <ChatFurtherQuestions message={message} />}
|
||||
<LlamaIndexChatMessage.Actions className="flex-1 flex-col" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<LlamaIndexChatMessage.Content className="flex-1">
|
||||
<LlamaIndexChatMessage.Content.Markdown languageRenderers={languageRenderers} />
|
||||
</LlamaIndexChatMessage.Content>
|
||||
)}
|
||||
</LlamaIndexChatMessage>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { getAnnotationData, type Message } from "@llamaindex/chat-ui";
|
||||
import { IconBrandGithub } from "@tabler/icons-react";
|
||||
import { ExternalLink, FileText, Globe } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { getAnnotationData, Message } from "@llamaindex/chat-ui";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -11,16 +15,6 @@ import {
|
|||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ExternalLink, FileText, Globe } from "lucide-react";
|
||||
import { IconBrandGithub } from "@tabler/icons-react";
|
||||
|
||||
interface Source {
|
||||
id: string;
|
||||
|
|
@ -50,10 +44,6 @@ interface SourceNode {
|
|||
metadata: NodeMetadata;
|
||||
}
|
||||
|
||||
interface NodesResponse {
|
||||
nodes: SourceNode[];
|
||||
}
|
||||
|
||||
function getSourceIcon(type: string) {
|
||||
switch (type) {
|
||||
case "USER_SELECTED_GITHUB_CONNECTOR":
|
||||
|
|
@ -113,12 +103,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
|||
const allNodes: SourceNode[] = [];
|
||||
|
||||
annotations.forEach((item) => {
|
||||
if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
"nodes" in item &&
|
||||
Array.isArray(item.nodes)
|
||||
) {
|
||||
if (item && typeof item === "object" && "nodes" in item && Array.isArray(item.nodes)) {
|
||||
allNodes.push(...item.nodes);
|
||||
}
|
||||
});
|
||||
|
|
@ -133,7 +118,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
|||
acc[sourceType].push(node);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, SourceNode[]>,
|
||||
{} as Record<string, SourceNode[]>
|
||||
);
|
||||
|
||||
// Convert grouped nodes to SourceGroup format
|
||||
|
|
@ -159,10 +144,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
|||
return null;
|
||||
}
|
||||
|
||||
const totalSources = sourceGroups.reduce(
|
||||
(acc, group) => acc + group.sources.length,
|
||||
0,
|
||||
);
|
||||
const totalSources = sourceGroups.reduce((acc, group) => acc + group.sources.length, 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
|
|
@ -176,10 +158,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
|||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Sources</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs
|
||||
defaultValue={sourceGroups[0]?.type}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<Tabs defaultValue={sourceGroups[0]?.type} className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex-shrink-0 w-full overflow-x-auto">
|
||||
<TabsList className="flex w-max min-w-full">
|
||||
{sourceGroups.map((group) => (
|
||||
|
|
@ -189,13 +168,8 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
|||
className="flex items-center gap-2 whitespace-nowrap px-3 md:px-4"
|
||||
>
|
||||
{getSourceIcon(group.type)}
|
||||
<span className="truncate max-w-[100px] md:max-w-none">
|
||||
{group.name}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1 h-5 text-xs flex-shrink-0"
|
||||
>
|
||||
<span className="truncate max-w-[100px] md:max-w-none">{group.name}</span>
|
||||
<Badge variant="secondary" className="ml-1 h-5 text-xs flex-shrink-0">
|
||||
{group.sources.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
|
|
@ -203,11 +177,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
|||
</TabsList>
|
||||
</div>
|
||||
{sourceGroups.map((group) => (
|
||||
<TabsContent
|
||||
key={group.type}
|
||||
value={group.type}
|
||||
className="flex-1 min-h-0 mt-4"
|
||||
>
|
||||
<TabsContent key={group.type} value={group.type} className="flex-1 min-h-0 mt-4">
|
||||
<div className="h-full overflow-y-auto pr-2">
|
||||
<div className="space-y-3">
|
||||
{group.sources.map((source) => (
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue