mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-30 21:59:46 +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"
|
echo "Running file quality hooks on changed files against $BASE_REF"
|
||||||
|
|
||||||
# Run each hook individually on changed files
|
# Run each hook individually on changed files
|
||||||
SKIP=detect-secrets,bandit,ruff,ruff-format,prettier,eslint,typescript-check-web,typescript-check-extension,commitizen \
|
SKIP=detect-secrets,bandit,ruff,ruff-format,biome-check-web,biome-check-extension,commitizen \
|
||||||
pre-commit run --from-ref $BASE_REF --to-ref HEAD || exit_code=$?
|
pre-commit run --from-ref $BASE_REF --to-ref HEAD || exit_code=$?
|
||||||
|
|
||||||
# Exit with the same code as pre-commit
|
# Exit with the same code as pre-commit
|
||||||
|
|
@ -118,7 +118,7 @@ jobs:
|
||||||
echo "Running security scans on changed files against $BASE_REF"
|
echo "Running security scans on changed files against $BASE_REF"
|
||||||
|
|
||||||
# Run only security hooks on changed files
|
# Run only security hooks on changed files
|
||||||
SKIP=check-yaml,check-json,check-toml,check-merge-conflict,check-added-large-files,debug-statements,check-case-conflict,ruff,ruff-format,prettier,eslint,typescript-check-web,typescript-check-extension,commitizen \
|
SKIP=check-yaml,check-json,check-toml,check-merge-conflict,check-added-large-files,debug-statements,check-case-conflict,ruff,ruff-format,biome-check-web,biome-check-extension,commitizen \
|
||||||
pre-commit run --from-ref $BASE_REF --to-ref HEAD || exit_code=$?
|
pre-commit run --from-ref $BASE_REF --to-ref HEAD || exit_code=$?
|
||||||
|
|
||||||
# Exit with the same code as pre-commit
|
# Exit with the same code as pre-commit
|
||||||
|
|
@ -199,7 +199,89 @@ jobs:
|
||||||
echo "Running Python backend checks on changed files against $BASE_REF"
|
echo "Running Python backend checks on changed files against $BASE_REF"
|
||||||
|
|
||||||
# Run only ruff hooks on changed Python files
|
# Run only ruff hooks on changed Python files
|
||||||
SKIP=detect-secrets,bandit,check-yaml,check-json,check-toml,check-merge-conflict,check-added-large-files,debug-statements,check-case-conflict,prettier,eslint,typescript-check-web,typescript-check-extension,commitizen \
|
SKIP=detect-secrets,bandit,check-yaml,check-json,check-toml,check-merge-conflict,check-added-large-files,debug-statements,check-case-conflict,biome-check-web,biome-check-extension,commitizen \
|
||||||
|
pre-commit run --from-ref $BASE_REF --to-ref HEAD || exit_code=$?
|
||||||
|
|
||||||
|
# Exit with the same code as pre-commit
|
||||||
|
exit ${exit_code:-0}
|
||||||
|
|
||||||
|
typescript-frontend:
|
||||||
|
name: TypeScript/JavaScript Quality
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event.pull_request.draft == false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Fetch base branch
|
||||||
|
run: |
|
||||||
|
git fetch origin ${{ github.base_ref }}:${{ github.base_ref }} 2>/dev/null || git fetch origin ${{ github.base_ref }} 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '18'
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- name: Check if frontend files changed
|
||||||
|
id: frontend-changes
|
||||||
|
uses: dorny/paths-filter@v3
|
||||||
|
with:
|
||||||
|
filters: |
|
||||||
|
web:
|
||||||
|
- 'surfsense_web/**'
|
||||||
|
extension:
|
||||||
|
- 'surfsense_browser_extension/**'
|
||||||
|
|
||||||
|
- name: Install dependencies for web
|
||||||
|
if: steps.frontend-changes.outputs.web == 'true'
|
||||||
|
working-directory: surfsense_web
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install dependencies for extension
|
||||||
|
if: steps.frontend-changes.outputs.extension == 'true'
|
||||||
|
working-directory: surfsense_browser_extension
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Install pre-commit
|
||||||
|
run: pip install pre-commit
|
||||||
|
|
||||||
|
- name: Cache pre-commit hooks
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/pre-commit
|
||||||
|
key: pre-commit-frontend-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
pre-commit-frontend-
|
||||||
|
|
||||||
|
- name: Install hook environments (cache)
|
||||||
|
run: pre-commit install-hooks
|
||||||
|
|
||||||
|
- name: Run TypeScript/JavaScript quality checks
|
||||||
|
run: |
|
||||||
|
# Get base ref for comparison
|
||||||
|
if git show-ref --verify --quiet refs/heads/${{ github.base_ref }}; then
|
||||||
|
BASE_REF="${{ github.base_ref }}"
|
||||||
|
elif git show-ref --verify --quiet refs/remotes/origin/${{ github.base_ref }}; then
|
||||||
|
BASE_REF="origin/${{ github.base_ref }}"
|
||||||
|
else
|
||||||
|
echo "Base branch reference not found, running TypeScript/JavaScript checks on all files"
|
||||||
|
pre-commit run --all-files biome-check-web biome-check-extension
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running TypeScript/JavaScript checks on changed files against $BASE_REF"
|
||||||
|
|
||||||
|
# Run only Biome hooks on changed TypeScript/JavaScript files
|
||||||
|
# Biome hooks use --diagnostic-level=error to only fail on errors, not warnings
|
||||||
|
SKIP=detect-secrets,bandit,check-yaml,check-json,check-toml,check-merge-conflict,check-added-large-files,debug-statements,check-case-conflict,ruff,ruff-format,commitizen \
|
||||||
pre-commit run --from-ref $BASE_REF --to-ref HEAD || exit_code=$?
|
pre-commit run --from-ref $BASE_REF --to-ref HEAD || exit_code=$?
|
||||||
|
|
||||||
# Exit with the same code as pre-commit
|
# Exit with the same code as pre-commit
|
||||||
|
|
@ -208,7 +290,7 @@ jobs:
|
||||||
quality-gate:
|
quality-gate:
|
||||||
name: Quality Gate
|
name: Quality Gate
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [file-quality, security-scan, python-backend]
|
needs: [file-quality, security-scan, python-backend, typescript-frontend]
|
||||||
if: always()
|
if: always()
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -216,7 +298,8 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ needs.file-quality.result }}" == "failure" ||
|
if [[ "${{ needs.file-quality.result }}" == "failure" ||
|
||||||
"${{ needs.security-scan.result }}" == "failure" ||
|
"${{ needs.security-scan.result }}" == "failure" ||
|
||||||
"${{ needs.python-backend.result }}" == "failure" ]]; then
|
"${{ needs.python-backend.result }}" == "failure" ||
|
||||||
|
"${{ needs.typescript-frontend.result }}" == "failure" ]]; then
|
||||||
echo "❌ Code quality checks failed"
|
echo "❌ Code quality checks failed"
|
||||||
exit 1
|
exit 1
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -60,45 +60,28 @@ repos:
|
||||||
args: ['-f', 'json', '--severity-level', 'high', '--confidence-level', 'high']
|
args: ['-f', 'json', '--severity-level', 'high', '--confidence-level', 'high']
|
||||||
exclude: ^surfsense_backend/(tests/|test_.*\.py|.*test.*\.py|alembic/)
|
exclude: ^surfsense_backend/(tests/|test_.*\.py|.*test.*\.py|alembic/)
|
||||||
|
|
||||||
# Frontend/Extension Hooks (TypeScript/JavaScript)
|
# Biome hooks for TypeScript/JavaScript projects
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
|
||||||
rev: v4.0.0-alpha.8
|
|
||||||
hooks:
|
|
||||||
- id: prettier
|
|
||||||
files: ^(surfsense_web|surfsense_browser_extension)/
|
|
||||||
types_or: [javascript, jsx, ts, tsx, json, yaml, markdown]
|
|
||||||
exclude: '(package-lock\.json|\.next/|build/|dist/)'
|
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
|
||||||
rev: v9.31.0
|
|
||||||
hooks:
|
|
||||||
- id: eslint
|
|
||||||
files: ^surfsense_web/
|
|
||||||
types: [file]
|
|
||||||
types_or: [javascript, jsx, ts, tsx]
|
|
||||||
additional_dependencies:
|
|
||||||
- 'eslint@^9'
|
|
||||||
- 'eslint-config-next@15.2.0'
|
|
||||||
- '@eslint/eslintrc@^3'
|
|
||||||
args: [--fix]
|
|
||||||
exclude: '(\.next/|build/|dist/)'
|
|
||||||
|
|
||||||
# TypeScript compilation check
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: typescript-check-web
|
# Biome check for surfsense_web
|
||||||
name: TypeScript Check (Web)
|
- id: biome-check-web
|
||||||
entry: bash -c 'cd surfsense_web && (command -v pnpm >/dev/null 2>&1 && pnpm build --dry-run || npx next build --dry-run)'
|
name: biome-check-web
|
||||||
|
entry: bash -c 'cd surfsense_web && npx @biomejs/biome check --diagnostic-level=error .'
|
||||||
language: system
|
language: system
|
||||||
files: ^surfsense_web/.*\.(ts|tsx)$
|
files: ^surfsense_web/
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
stages: [pre-commit]
|
||||||
|
|
||||||
- id: typescript-check-extension
|
# Biome check for surfsense_browser_extension
|
||||||
name: TypeScript Check (Browser Extension)
|
- id: biome-check-extension
|
||||||
entry: bash -c 'cd surfsense_browser_extension && npx tsc --noEmit'
|
name: biome-check-extension
|
||||||
|
entry: bash -c 'cd surfsense_browser_extension && npx @biomejs/biome check --diagnostic-level=error .'
|
||||||
language: system
|
language: system
|
||||||
files: ^surfsense_browser_extension/.*\.(ts|tsx)$
|
files: ^surfsense_browser_extension/
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
always_run: true
|
||||||
|
stages: [pre-commit]
|
||||||
|
|
||||||
# Commit message linting
|
# Commit message linting
|
||||||
- repo: https://github.com/commitizen-tools/commitizen
|
- repo: https://github.com/commitizen-tools/commitizen
|
||||||
|
|
|
||||||
3
.vscode/settings.json
vendored
Normal file
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>"
|
chat_history_str += f"<user_message>{message['content']}</user_message>"
|
||||||
processed_messages += 1
|
processed_messages += 1
|
||||||
elif message["role"] == "assistant":
|
elif message["role"] == "assistant":
|
||||||
# Last annotation type will always be "ANSWER" here
|
chat_history_str += (
|
||||||
answer_annotation = message["annotations"][-1]
|
f"<assistant_message>{message['content']}</assistant_message>"
|
||||||
answer_text = ""
|
)
|
||||||
if answer_annotation["type"] == "ANSWER":
|
processed_messages += 1
|
||||||
answer_text = answer_annotation["content"]
|
|
||||||
# If content is a list, join it into a single string
|
|
||||||
if isinstance(answer_text, list):
|
|
||||||
answer_text = "\n".join(answer_text)
|
|
||||||
chat_history_str += (
|
|
||||||
f"<assistant_message>{answer_text}</assistant_message>"
|
|
||||||
)
|
|
||||||
processed_messages += 1
|
|
||||||
|
|
||||||
chat_history_str += "</chat_history>"
|
chat_history_str += "</chat_history>"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { Storage } from "@plasmohq/storage";
|
||||||
import type { WebHistory } from "~utils/interfaces"
|
import { getRenderedHtml, initQueues, initWebHistory } from "~utils/commons";
|
||||||
import { Storage } from "@plasmohq/storage"
|
import type { WebHistory } from "~utils/interfaces";
|
||||||
import {getRenderedHtml} from '~utils/commons'
|
|
||||||
|
|
||||||
chrome.tabs.onCreated.addListener(async (tab: any) => {
|
chrome.tabs.onCreated.addListener(async (tab: any) => {
|
||||||
try {
|
try {
|
||||||
await initWebHistory(tab.id)
|
await initWebHistory(tab.id);
|
||||||
await initQueues(tab.id)
|
await initQueues(tab.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
chrome.tabs.onUpdated.addListener(
|
chrome.tabs.onUpdated.addListener(async (tabId: number, changeInfo: any, tab: any) => {
|
||||||
async (tabId: number, changeInfo: any, tab: any) => {
|
if (changeInfo.status === "complete" && tab.url) {
|
||||||
if (changeInfo.status === "complete" && tab.url) {
|
const storage = new Storage({ area: "local" });
|
||||||
const storage = new Storage({ area: "local" })
|
await initWebHistory(tab.id);
|
||||||
await initWebHistory(tab.id)
|
await initQueues(tab.id);
|
||||||
await initQueues(tab.id)
|
|
||||||
|
|
||||||
const result = await chrome.scripting.executeScript({
|
const result = await chrome.scripting.executeScript({
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
target: { tabId: tab.id },
|
target: { tabId: tab.id },
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
func: getRenderedHtml
|
func: getRenderedHtml,
|
||||||
})
|
});
|
||||||
|
|
||||||
let toPushInTabHistory: any = result[0].result // const { renderedHtml, title, url, entryTime } = result[0].result;
|
const toPushInTabHistory: any = result[0].result; // const { renderedHtml, title, url, entryTime } = result[0].result;
|
||||||
|
|
||||||
let urlQueueListObj: any = await storage.get("urlQueueList")
|
const urlQueueListObj: any = await storage.get("urlQueueList");
|
||||||
let timeQueueListObj: any = await storage.get("timeQueueList")
|
const timeQueueListObj: any = await storage.get("timeQueueList");
|
||||||
|
|
||||||
urlQueueListObj.urlQueueList
|
urlQueueListObj.urlQueueList
|
||||||
.find((data: WebHistory) => data.tabsessionId === tabId)
|
.find((data: WebHistory) => data.tabsessionId === tabId)
|
||||||
.urlQueue.push(toPushInTabHistory.url)
|
.urlQueue.push(toPushInTabHistory.url);
|
||||||
timeQueueListObj.timeQueueList
|
timeQueueListObj.timeQueueList
|
||||||
.find((data: WebHistory) => data.tabsessionId === tabId)
|
.find((data: WebHistory) => data.tabsessionId === tabId)
|
||||||
.timeQueue.push(toPushInTabHistory.entryTime)
|
.timeQueue.push(toPushInTabHistory.entryTime);
|
||||||
|
|
||||||
await storage.set("urlQueueList", {
|
await storage.set("urlQueueList", {
|
||||||
urlQueueList: urlQueueListObj.urlQueueList
|
urlQueueList: urlQueueListObj.urlQueueList,
|
||||||
})
|
});
|
||||||
await storage.set("timeQueueList", {
|
await storage.set("timeQueueList", {
|
||||||
timeQueueList: timeQueueListObj.timeQueueList
|
timeQueueList: timeQueueListObj.timeQueueList,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
)
|
|
||||||
|
|
||||||
chrome.tabs.onRemoved.addListener(async (tabId: number, removeInfo: object) => {
|
chrome.tabs.onRemoved.addListener(async (tabId: number, removeInfo: object) => {
|
||||||
const storage = new Storage({ area: "local" })
|
const storage = new Storage({ area: "local" });
|
||||||
let urlQueueListObj: any = await storage.get("urlQueueList")
|
const urlQueueListObj: any = await storage.get("urlQueueList");
|
||||||
let timeQueueListObj: any = await storage.get("timeQueueList")
|
const timeQueueListObj: any = await storage.get("timeQueueList");
|
||||||
if (urlQueueListObj.urlQueueList && timeQueueListObj.timeQueueList) {
|
if (urlQueueListObj.urlQueueList && timeQueueListObj.timeQueueList) {
|
||||||
const urlQueueListToSave = urlQueueListObj.urlQueueList.map(
|
const urlQueueListToSave = urlQueueListObj.urlQueueList.map((element: WebHistory) => {
|
||||||
(element: WebHistory) => {
|
if (element.tabsessionId !== tabId) {
|
||||||
if (element.tabsessionId !== tabId) {
|
return element;
|
||||||
return element
|
}
|
||||||
}
|
});
|
||||||
}
|
const timeQueueListSave = timeQueueListObj.timeQueueList.map((element: WebHistory) => {
|
||||||
)
|
if (element.tabsessionId !== tabId) {
|
||||||
const timeQueueListSave = timeQueueListObj.timeQueueList.map(
|
return element;
|
||||||
(element: WebHistory) => {
|
}
|
||||||
if (element.tabsessionId !== tabId) {
|
});
|
||||||
return element
|
await storage.set("urlQueueList", {
|
||||||
}
|
urlQueueList: urlQueueListToSave.filter((item: any) => item),
|
||||||
}
|
});
|
||||||
)
|
await storage.set("timeQueueList", {
|
||||||
await storage.set("urlQueueList", {
|
timeQueueList: timeQueueListSave.filter((item: any) => item),
|
||||||
urlQueueList: urlQueueListToSave.filter((item: any) => item)
|
});
|
||||||
})
|
}
|
||||||
await storage.set("timeQueueList", {
|
});
|
||||||
timeQueueList: timeQueueListSave.filter((item: any) => item)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -1,149 +1,150 @@
|
||||||
import type { PlasmoMessaging } from "@plasmohq/messaging"
|
import type { PlasmoMessaging } from "@plasmohq/messaging";
|
||||||
import { Storage } from "@plasmohq/storage"
|
import { Storage } from "@plasmohq/storage";
|
||||||
|
|
||||||
import {
|
import { emptyArr, webhistoryToLangChainDocument } from "~utils/commons";
|
||||||
emptyArr,
|
|
||||||
webhistoryToLangChainDocument
|
|
||||||
} from "~utils/commons"
|
|
||||||
|
|
||||||
const clearMemory = async () => {
|
const clearMemory = async () => {
|
||||||
try {
|
try {
|
||||||
const storage = new Storage({ area: "local" })
|
const storage = new Storage({ area: "local" });
|
||||||
|
|
||||||
let webHistory: any = await storage.get("webhistory")
|
const webHistory: any = await storage.get("webhistory");
|
||||||
let urlQueue: any = await storage.get("urlQueueList")
|
const urlQueue: any = await storage.get("urlQueueList");
|
||||||
let timeQueue: any = await storage.get("timeQueueList")
|
const timeQueue: any = await storage.get("timeQueueList");
|
||||||
|
|
||||||
if (!webHistory.webhistory) {
|
if (!webHistory.webhistory) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
//Main Cleanup COde
|
//Main Cleanup COde
|
||||||
chrome.tabs.query({}, async (tabs) => {
|
chrome.tabs.query({}, async (tabs) => {
|
||||||
//Get Active Tabs Ids
|
//Get Active Tabs Ids
|
||||||
// console.log("Event Tabs",tabs)
|
// console.log("Event Tabs",tabs)
|
||||||
let actives = tabs.map((tab) => {
|
let actives = tabs.map((tab) => {
|
||||||
if (tab.id) {
|
if (tab.id) {
|
||||||
return tab.id
|
return tab.id;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
actives = actives.filter((item: any) => item)
|
actives = actives.filter((item: any) => item);
|
||||||
|
|
||||||
//Only retain which is still active
|
//Only retain which is still active
|
||||||
const newHistory = webHistory.webhistory.map((element: any) => {
|
const newHistory = webHistory.webhistory.map((element: any) => {
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
if (actives.includes(element.tabsessionId)) {
|
if (actives.includes(element.tabsessionId)) {
|
||||||
return element
|
return element;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const newUrlQueue = urlQueue.urlQueueList.map((element: any) => {
|
const newUrlQueue = urlQueue.urlQueueList.map((element: any) => {
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
if (actives.includes(element.tabsessionId)) {
|
if (actives.includes(element.tabsessionId)) {
|
||||||
return element
|
return element;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const newTimeQueue = timeQueue.timeQueueList.map((element: any) => {
|
const newTimeQueue = timeQueue.timeQueueList.map((element: any) => {
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
if (actives.includes(element.tabsessionId)) {
|
if (actives.includes(element.tabsessionId)) {
|
||||||
return element
|
return element;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
await storage.set("webhistory", {
|
await storage.set("webhistory", {
|
||||||
webhistory: newHistory.filter((item: any) => item)
|
webhistory: newHistory.filter((item: any) => item),
|
||||||
})
|
});
|
||||||
await storage.set("urlQueueList", {
|
await storage.set("urlQueueList", {
|
||||||
urlQueueList: newUrlQueue.filter((item: any) => item)
|
urlQueueList: newUrlQueue.filter((item: any) => item),
|
||||||
})
|
});
|
||||||
await storage.set("timeQueueList", {
|
await storage.set("timeQueueList", {
|
||||||
timeQueueList: newTimeQueue.filter((item: any) => item)
|
timeQueueList: newTimeQueue.filter((item: any) => item),
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
|
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const storage = new Storage({ area: "local" })
|
const storage = new Storage({ area: "local" });
|
||||||
|
|
||||||
const webhistoryObj: any = await storage.get("webhistory")
|
const webhistoryObj: any = await storage.get("webhistory");
|
||||||
const webhistory = webhistoryObj.webhistory
|
const webhistory = webhistoryObj.webhistory;
|
||||||
if (webhistory) {
|
if (webhistory) {
|
||||||
let toSaveFinally: any[] = []
|
const toSaveFinally: any[] = [];
|
||||||
let newHistoryAfterCleanup: any[] = []
|
const newHistoryAfterCleanup: any[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < webhistory.length; i++) {
|
for (let i = 0; i < webhistory.length; i++) {
|
||||||
const markdownFormat = webhistoryToLangChainDocument(
|
const markdownFormat = webhistoryToLangChainDocument(
|
||||||
webhistory[i].tabsessionId,
|
webhistory[i].tabsessionId,
|
||||||
webhistory[i].tabHistory
|
webhistory[i].tabHistory
|
||||||
)
|
);
|
||||||
toSaveFinally.push(...markdownFormat)
|
toSaveFinally.push(...markdownFormat);
|
||||||
newHistoryAfterCleanup.push({
|
newHistoryAfterCleanup.push({
|
||||||
tabsessionId: webhistory[i].tabsessionId,
|
tabsessionId: webhistory[i].tabsessionId,
|
||||||
tabHistory: emptyArr
|
tabHistory: emptyArr,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await storage.set("webhistory",{ webhistory: newHistoryAfterCleanup });
|
await storage.set("webhistory", { webhistory: newHistoryAfterCleanup });
|
||||||
|
|
||||||
// Log first item to debug metadata structure
|
// Log first item to debug metadata structure
|
||||||
if (toSaveFinally.length > 0) {
|
if (toSaveFinally.length > 0) {
|
||||||
console.log("First item metadata:", toSaveFinally[0].metadata);
|
console.log("First item metadata:", toSaveFinally[0].metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create content array for documents in the format expected by the new API
|
// Create content array for documents in the format expected by the new API
|
||||||
const content = toSaveFinally.map(item => ({
|
const content = toSaveFinally.map((item) => ({
|
||||||
metadata: {
|
metadata: {
|
||||||
BrowsingSessionId: String(item.metadata.BrowsingSessionId || ""),
|
BrowsingSessionId: String(item.metadata.BrowsingSessionId || ""),
|
||||||
VisitedWebPageURL: String(item.metadata.VisitedWebPageURL || ""),
|
VisitedWebPageURL: String(item.metadata.VisitedWebPageURL || ""),
|
||||||
VisitedWebPageTitle: String(item.metadata.VisitedWebPageTitle || "No Title"),
|
VisitedWebPageTitle: String(item.metadata.VisitedWebPageTitle || "No Title"),
|
||||||
VisitedWebPageDateWithTimeInISOString: String(item.metadata.VisitedWebPageDateWithTimeInISOString || ""),
|
VisitedWebPageDateWithTimeInISOString: String(
|
||||||
VisitedWebPageReffererURL: String(item.metadata.VisitedWebPageReffererURL || ""),
|
item.metadata.VisitedWebPageDateWithTimeInISOString || ""
|
||||||
VisitedWebPageVisitDurationInMilliseconds: String(item.metadata.VisitedWebPageVisitDurationInMilliseconds || "0")
|
),
|
||||||
},
|
VisitedWebPageReffererURL: String(item.metadata.VisitedWebPageReffererURL || ""),
|
||||||
pageContent: String(item.pageContent || "")
|
VisitedWebPageVisitDurationInMilliseconds: String(
|
||||||
}));
|
item.metadata.VisitedWebPageVisitDurationInMilliseconds || "0"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
pageContent: String(item.pageContent || ""),
|
||||||
|
}));
|
||||||
|
|
||||||
const token = await storage.get("token");
|
const token = await storage.get("token");
|
||||||
const search_space_id = parseInt(await storage.get("search_space_id"), 10);
|
const search_space_id = parseInt(await storage.get("search_space_id"), 10);
|
||||||
|
|
||||||
const toSend = {
|
const toSend = {
|
||||||
document_type: "EXTENSION",
|
document_type: "EXTENSION",
|
||||||
content: content,
|
content: content,
|
||||||
search_space_id: search_space_id
|
search_space_id: search_space_id,
|
||||||
}
|
};
|
||||||
|
|
||||||
console.log("toSend", toSend)
|
console.log("toSend", toSend);
|
||||||
|
|
||||||
const requestOptions = {
|
const requestOptions = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": `Bearer ${token}`
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(toSend)
|
body: JSON.stringify(toSend),
|
||||||
}
|
};
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/documents/`,
|
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/documents/`,
|
||||||
requestOptions
|
requestOptions
|
||||||
)
|
);
|
||||||
const resp = await response.json()
|
const resp = await response.json();
|
||||||
if (resp) {
|
if (resp) {
|
||||||
await clearMemory()
|
await clearMemory();
|
||||||
res.send({
|
res.send({
|
||||||
message: "Save Job Started"
|
message: "Save Job Started",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export default handler
|
export default handler;
|
||||||
|
|
|
||||||
|
|
@ -1,145 +1,142 @@
|
||||||
import { DOMParser } from "linkedom"
|
import type { PlasmoMessaging } from "@plasmohq/messaging";
|
||||||
|
|
||||||
import { Storage } from "@plasmohq/storage"
|
import { Storage } from "@plasmohq/storage";
|
||||||
import type { PlasmoMessaging } from "@plasmohq/messaging"
|
import { convertHtmlToMarkdown } from "dom-to-semantic-markdown";
|
||||||
|
import { DOMParser } from "linkedom";
|
||||||
import type { WebHistory } from "~utils/interfaces"
|
import { getRenderedHtml, webhistoryToLangChainDocument } from "~utils/commons";
|
||||||
import { webhistoryToLangChainDocument, getRenderedHtml } from "~utils/commons"
|
import type { WebHistory } from "~utils/interfaces";
|
||||||
import { convertHtmlToMarkdown } from "dom-to-semantic-markdown"
|
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
global.Node = {
|
global.Node = {
|
||||||
ELEMENT_NODE: 1,
|
ELEMENT_NODE: 1,
|
||||||
ATTRIBUTE_NODE: 2,
|
ATTRIBUTE_NODE: 2,
|
||||||
TEXT_NODE: 3,
|
TEXT_NODE: 3,
|
||||||
CDATA_SECTION_NODE: 4,
|
CDATA_SECTION_NODE: 4,
|
||||||
PROCESSING_INSTRUCTION_NODE: 7,
|
PROCESSING_INSTRUCTION_NODE: 7,
|
||||||
COMMENT_NODE: 8,
|
COMMENT_NODE: 8,
|
||||||
DOCUMENT_NODE: 9,
|
DOCUMENT_NODE: 9,
|
||||||
DOCUMENT_TYPE_NODE: 10,
|
DOCUMENT_TYPE_NODE: 10,
|
||||||
DOCUMENT_FRAGMENT_NODE: 11,
|
DOCUMENT_FRAGMENT_NODE: 11,
|
||||||
};
|
};
|
||||||
|
|
||||||
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
|
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
chrome.tabs.query(
|
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
|
||||||
{ active: true, currentWindow: true },
|
const storage = new Storage({ area: "local" });
|
||||||
async function (tabs) {
|
const tab = tabs[0];
|
||||||
const storage = new Storage({ area: "local" })
|
if (tab.id) {
|
||||||
const tab = tabs[0]
|
const tabId: number = tab.id;
|
||||||
if (tab.id) {
|
console.log("tabs", tabs);
|
||||||
const tabId: number = tab.id
|
const result = await chrome.scripting.executeScript({
|
||||||
console.log("tabs", tabs)
|
// @ts-ignore
|
||||||
const result = await chrome.scripting.executeScript({
|
target: { tabId: tab.id },
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
target: { tabId: tab.id },
|
func: getRenderedHtml,
|
||||||
// @ts-ignore
|
// world: "MAIN"
|
||||||
func: getRenderedHtml,
|
});
|
||||||
// world: "MAIN"
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log("SnapRes", result)
|
console.log("SnapRes", result);
|
||||||
|
|
||||||
let toPushInTabHistory: any = result[0].result // const { renderedHtml, title, url, entryTime } = result[0].result;
|
const toPushInTabHistory: any = result[0].result; // const { renderedHtml, title, url, entryTime } = result[0].result;
|
||||||
|
|
||||||
toPushInTabHistory.pageContentMarkdown = convertHtmlToMarkdown(
|
toPushInTabHistory.pageContentMarkdown = convertHtmlToMarkdown(
|
||||||
toPushInTabHistory.renderedHtml,
|
toPushInTabHistory.renderedHtml,
|
||||||
{
|
{
|
||||||
extractMainContent: true,
|
extractMainContent: true,
|
||||||
enableTableColumnTracking: true,
|
enableTableColumnTracking: true,
|
||||||
includeMetaData: false,
|
includeMetaData: false,
|
||||||
overrideDOMParser: new DOMParser()
|
overrideDOMParser: new DOMParser(),
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
delete toPushInTabHistory.renderedHtml
|
delete toPushInTabHistory.renderedHtml;
|
||||||
|
|
||||||
console.log("toPushInTabHistory", toPushInTabHistory)
|
console.log("toPushInTabHistory", toPushInTabHistory);
|
||||||
|
|
||||||
const urlQueueListObj: any = await storage.get("urlQueueList")
|
const urlQueueListObj: any = await storage.get("urlQueueList");
|
||||||
const timeQueueListObj: any = await storage.get("timeQueueList")
|
const timeQueueListObj: any = await storage.get("timeQueueList");
|
||||||
|
|
||||||
const isUrlQueueThere = urlQueueListObj.urlQueueList.find(
|
const isUrlQueueThere = urlQueueListObj.urlQueueList.find(
|
||||||
(data: WebHistory) => data.tabsessionId === tabId
|
(data: WebHistory) => data.tabsessionId === tabId
|
||||||
)
|
);
|
||||||
const isTimeQueueThere = timeQueueListObj.timeQueueList.find(
|
const isTimeQueueThere = timeQueueListObj.timeQueueList.find(
|
||||||
(data: WebHistory) => data.tabsessionId === tabId
|
(data: WebHistory) => data.tabsessionId === tabId
|
||||||
)
|
);
|
||||||
|
|
||||||
toPushInTabHistory.duration =
|
toPushInTabHistory.duration =
|
||||||
toPushInTabHistory.entryTime -
|
toPushInTabHistory.entryTime -
|
||||||
isTimeQueueThere.timeQueue[isTimeQueueThere.timeQueue.length - 1]
|
isTimeQueueThere.timeQueue[isTimeQueueThere.timeQueue.length - 1];
|
||||||
if (isUrlQueueThere.urlQueue.length == 1) {
|
if (isUrlQueueThere.urlQueue.length === 1) {
|
||||||
toPushInTabHistory.reffererUrl = "START"
|
toPushInTabHistory.reffererUrl = "START";
|
||||||
}
|
}
|
||||||
if (isUrlQueueThere.urlQueue.length > 1) {
|
if (isUrlQueueThere.urlQueue.length > 1) {
|
||||||
toPushInTabHistory.reffererUrl =
|
toPushInTabHistory.reffererUrl =
|
||||||
isUrlQueueThere.urlQueue[isUrlQueueThere.urlQueue.length - 2]
|
isUrlQueueThere.urlQueue[isUrlQueueThere.urlQueue.length - 2];
|
||||||
}
|
}
|
||||||
|
|
||||||
let toSaveFinally: any[] = []
|
const toSaveFinally: any[] = [];
|
||||||
|
|
||||||
const markdownFormat = webhistoryToLangChainDocument(
|
const markdownFormat = webhistoryToLangChainDocument(tab.id, [toPushInTabHistory]);
|
||||||
tab.id,
|
toSaveFinally.push(...markdownFormat);
|
||||||
[toPushInTabHistory]
|
|
||||||
)
|
|
||||||
toSaveFinally.push(...markdownFormat)
|
|
||||||
|
|
||||||
console.log("toSaveFinally", toSaveFinally)
|
|
||||||
|
|
||||||
// Log first item to debug metadata structure
|
console.log("toSaveFinally", toSaveFinally);
|
||||||
if (toSaveFinally.length > 0) {
|
|
||||||
console.log("First item metadata:", toSaveFinally[0].metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create content array for documents in the format expected by the new API
|
// Log first item to debug metadata structure
|
||||||
// The metadata is already in the correct format in toSaveFinally
|
if (toSaveFinally.length > 0) {
|
||||||
const content = toSaveFinally.map(item => ({
|
console.log("First item metadata:", toSaveFinally[0].metadata);
|
||||||
metadata: {
|
}
|
||||||
BrowsingSessionId: String(item.metadata.BrowsingSessionId || ""),
|
|
||||||
VisitedWebPageURL: String(item.metadata.VisitedWebPageURL || ""),
|
|
||||||
VisitedWebPageTitle: String(item.metadata.VisitedWebPageTitle || "No Title"),
|
|
||||||
VisitedWebPageDateWithTimeInISOString: String(item.metadata.VisitedWebPageDateWithTimeInISOString || ""),
|
|
||||||
VisitedWebPageReffererURL: String(item.metadata.VisitedWebPageReffererURL || ""),
|
|
||||||
VisitedWebPageVisitDurationInMilliseconds: String(item.metadata.VisitedWebPageVisitDurationInMilliseconds || "0")
|
|
||||||
},
|
|
||||||
pageContent: String(item.pageContent || "")
|
|
||||||
}));
|
|
||||||
|
|
||||||
const token = await storage.get("token");
|
// Create content array for documents in the format expected by the new API
|
||||||
const search_space_id = parseInt(await storage.get("search_space_id"), 10);
|
// The metadata is already in the correct format in toSaveFinally
|
||||||
|
const content = toSaveFinally.map((item) => ({
|
||||||
|
metadata: {
|
||||||
|
BrowsingSessionId: String(item.metadata.BrowsingSessionId || ""),
|
||||||
|
VisitedWebPageURL: String(item.metadata.VisitedWebPageURL || ""),
|
||||||
|
VisitedWebPageTitle: String(item.metadata.VisitedWebPageTitle || "No Title"),
|
||||||
|
VisitedWebPageDateWithTimeInISOString: String(
|
||||||
|
item.metadata.VisitedWebPageDateWithTimeInISOString || ""
|
||||||
|
),
|
||||||
|
VisitedWebPageReffererURL: String(item.metadata.VisitedWebPageReffererURL || ""),
|
||||||
|
VisitedWebPageVisitDurationInMilliseconds: String(
|
||||||
|
item.metadata.VisitedWebPageVisitDurationInMilliseconds || "0"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
pageContent: String(item.pageContent || ""),
|
||||||
|
}));
|
||||||
|
|
||||||
const toSend = {
|
const token = await storage.get("token");
|
||||||
document_type: "EXTENSION",
|
const search_space_id = parseInt(await storage.get("search_space_id"), 10);
|
||||||
content: content,
|
|
||||||
search_space_id: search_space_id
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestOptions = {
|
const toSend = {
|
||||||
method: "POST",
|
document_type: "EXTENSION",
|
||||||
headers: {
|
content: content,
|
||||||
"Content-Type": "application/json",
|
search_space_id: search_space_id,
|
||||||
"Authorization": `Bearer ${token}`
|
};
|
||||||
},
|
|
||||||
body: JSON.stringify(toSend)
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(
|
const requestOptions = {
|
||||||
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/documents/`,
|
method: "POST",
|
||||||
requestOptions
|
headers: {
|
||||||
)
|
"Content-Type": "application/json",
|
||||||
const resp = await response.json()
|
Authorization: `Bearer ${token}`,
|
||||||
if (resp) {
|
},
|
||||||
res.send({
|
body: JSON.stringify(toSend),
|
||||||
message: "Snapshot Saved Successfully"
|
};
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default handler
|
const response = await fetch(
|
||||||
|
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/documents/`,
|
||||||
|
requestOptions
|
||||||
|
);
|
||||||
|
const resp = await response.json();
|
||||||
|
if (resp) {
|
||||||
|
res.send({
|
||||||
|
message: "Snapshot Saved Successfully",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handler;
|
||||||
|
|
|
||||||
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"
|
import type { PlasmoCSConfig } from "plasmo";
|
||||||
|
|
||||||
export const config: PlasmoCSConfig = {
|
|
||||||
matches: ["<all_urls>"],
|
|
||||||
all_frames: true,
|
|
||||||
world: "MAIN"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export const config: PlasmoCSConfig = {
|
||||||
|
matches: ["<all_urls>"],
|
||||||
|
all_frames: true,
|
||||||
|
world: "MAIN",
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Fascinate";
|
font-family: "Fascinate";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
src: url(data-base64:~assets/Fascinate.woff2) format("woff2");
|
src: url(data-base64:~assets/Fascinate.woff2) format("woff2");
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
unicode-range:
|
||||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||||
U+FEFF, U+FFFD;
|
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
|
||||||
}
|
U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { clsx, type ClassValue } from "clsx"
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,62 +1,62 @@
|
||||||
{
|
{
|
||||||
"name": "surfsense_browser_extension",
|
"name": "surfsense_browser_extension",
|
||||||
"displayName": "Surfsense Browser Extension",
|
"displayName": "Surfsense Browser Extension",
|
||||||
"version": "0.0.7",
|
"version": "0.0.7",
|
||||||
"description": "Extension to collect Browsing History for SurfSense.",
|
"description": "Extension to collect Browsing History for SurfSense.",
|
||||||
"author": "https://github.com/MODSetter",
|
"author": "https://github.com/MODSetter",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "plasmo dev",
|
"dev": "plasmo dev",
|
||||||
"build": "plasmo build",
|
"build": "plasmo build",
|
||||||
"package": "plasmo package"
|
"package": "plasmo package"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@plasmohq/messaging": "^0.6.2",
|
"@plasmohq/messaging": "^0.6.2",
|
||||||
"@plasmohq/storage": "^1.11.0",
|
"@plasmohq/storage": "^1.11.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@radix-ui/react-popover": "^1.1.2",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-popover": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.2",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"@radix-ui/react-toast": "^1.2.2",
|
||||||
"clsx": "^2.1.1",
|
"class-variance-authority": "^0.7.0",
|
||||||
"cmdk": "^1.0.3",
|
"clsx": "^2.1.1",
|
||||||
"dom-to-semantic-markdown": "^1.2.11",
|
"cmdk": "^1.0.3",
|
||||||
"linkedom": "0.1.34",
|
"dom-to-semantic-markdown": "^1.2.11",
|
||||||
"lucide-react": "^0.454.0",
|
"linkedom": "0.1.34",
|
||||||
"plasmo": "0.89.4",
|
"lucide-react": "^0.454.0",
|
||||||
"postcss-loader": "^8.1.1",
|
"plasmo": "0.89.4",
|
||||||
"radix-ui": "^1.0.1",
|
"postcss-loader": "^8.1.1",
|
||||||
"react": "18.2.0",
|
"radix-ui": "^1.0.1",
|
||||||
"react-dom": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-hooks-global-state": "^2.1.0",
|
"react-dom": "18.2.0",
|
||||||
"react-router-dom": "^6.26.1",
|
"react-hooks-global-state": "^2.1.0",
|
||||||
"tailwind-merge": "^2.5.4",
|
"react-router-dom": "^6.26.1",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwind-merge": "^2.5.4",
|
||||||
},
|
"tailwindcss-animate": "^1.0.7"
|
||||||
"devDependencies": {
|
},
|
||||||
"@ianvs/prettier-plugin-sort-imports": "4.1.1",
|
"devDependencies": {
|
||||||
"@types/chrome": "0.0.258",
|
"@biomejs/biome": "2.1.2",
|
||||||
"@types/node": "20.11.5",
|
"@types/chrome": "0.0.258",
|
||||||
"@types/react": "18.2.48",
|
"@types/node": "20.11.5",
|
||||||
"@types/react-dom": "18.2.18",
|
"@types/react": "18.2.48",
|
||||||
"autoprefixer": "^10.4.20",
|
"@types/react-dom": "18.2.18",
|
||||||
"postcss": "^8.4.41",
|
"autoprefixer": "^10.4.20",
|
||||||
"prettier": "3.2.4",
|
"postcss": "^8.4.41",
|
||||||
"tailwindcss": "^3.4.10",
|
"tailwindcss": "^3.4.10",
|
||||||
"typescript": "5.3.3"
|
"typescript": "5.3.3"
|
||||||
},
|
},
|
||||||
"manifest": {
|
"manifest": {
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"<all_urls>"
|
"<all_urls>"
|
||||||
],
|
],
|
||||||
"name": "SurfSense",
|
"name": "SurfSense",
|
||||||
"description": "Extension to collect Browsing History for SurfSense.",
|
"description": "Extension to collect Browsing History for SurfSense.",
|
||||||
"version": "0.0.3"
|
"version": "0.0.3"
|
||||||
},
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage",
|
"storage",
|
||||||
"scripting",
|
"scripting",
|
||||||
"unlimitedStorage",
|
"unlimitedStorage",
|
||||||
"activeTab"
|
"activeTab"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { MemoryRouter } from "react-router-dom";
|
||||||
|
import { Toaster } from "@/routes/ui/toaster";
|
||||||
import { Routing } from "~routes"
|
import { Routing } from "~routes";
|
||||||
import { Toaster } from "@/routes/ui/toaster"
|
|
||||||
|
|
||||||
function IndexPopup() {
|
function IndexPopup() {
|
||||||
return (
|
return (
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<Routing />
|
<Routing />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default IndexPopup
|
export default IndexPopup;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import { Route, Routes } from "react-router-dom"
|
import { Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
import ApiKeyForm from "./pages/ApiKeyForm"
|
|
||||||
import HomePage from "./pages/HomePage"
|
|
||||||
import '../tailwind.css'
|
|
||||||
|
|
||||||
|
import ApiKeyForm from "./pages/ApiKeyForm";
|
||||||
|
import HomePage from "./pages/HomePage";
|
||||||
|
import "../tailwind.css";
|
||||||
|
|
||||||
export const Routing = () => (
|
export const Routing = () => (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/login" element={<ApiKeyForm />} />
|
<Route path="/login" element={<ApiKeyForm />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,123 +1,122 @@
|
||||||
import React, { useState } from "react";
|
import icon from "data-base64:~assets/icon.png";
|
||||||
import { useNavigate } from "react-router-dom"
|
import { Storage } from "@plasmohq/storage";
|
||||||
import icon from "data-base64:~assets/icon.png"
|
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||||
import { Storage } from "@plasmohq/storage"
|
import { useState } from "react";
|
||||||
import { Button } from "~/routes/ui/button"
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ReloadIcon } from "@radix-ui/react-icons"
|
import { Button } from "~/routes/ui/button";
|
||||||
|
|
||||||
const ApiKeyForm = () => {
|
const ApiKeyForm = () => {
|
||||||
const navigation = useNavigate()
|
const navigation = useNavigate();
|
||||||
const [apiKey, setApiKey] = useState('');
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const storage = new Storage({ area: "local" })
|
const storage = new Storage({ area: "local" });
|
||||||
|
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
setError('API key is required');
|
setError("API key is required");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
setError('');
|
setError("");
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (event: { preventDefault: () => void; }) => {
|
const handleSubmit = async (event: { preventDefault: () => void }) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!validateForm()) return;
|
if (!validateForm()) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verify token is valid by making a request to the API
|
// Verify token is valid by making a request to the API
|
||||||
const response = await fetch(`${process.env.PLASMO_PUBLIC_BACKEND_URL}/verify-token`, {
|
const response = await fetch(`${process.env.PLASMO_PUBLIC_BACKEND_URL}/verify-token`, {
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
Authorization: `Bearer ${apiKey}`,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Store the API key as the token
|
// Store the API key as the token
|
||||||
await storage.set('token', apiKey);
|
await storage.set("token", apiKey);
|
||||||
navigation("/")
|
navigation("/");
|
||||||
} else {
|
} else {
|
||||||
setError('Invalid API key. Please check and try again.');
|
setError("Invalid API key. Please check and try again.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError('An error occurred. Please try again later.');
|
setError("An error occurred. Please try again later.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 flex flex-col items-center justify-center p-6">
|
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 flex flex-col items-center justify-center p-6">
|
||||||
<div className="w-full max-w-md mx-auto space-y-8">
|
<div className="w-full max-w-md mx-auto space-y-8">
|
||||||
<div className="flex flex-col items-center space-y-2">
|
<div className="flex flex-col items-center space-y-2">
|
||||||
<div className="bg-gray-800 p-3 rounded-full ring-2 ring-gray-700 shadow-lg">
|
<div className="bg-gray-800 p-3 rounded-full ring-2 ring-gray-700 shadow-lg">
|
||||||
<img className="w-12 h-12" src={icon} alt="SurfSense" />
|
<img className="w-12 h-12" src={icon} alt="SurfSense" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-semibold tracking-tight text-white mt-4">SurfSense</h1>
|
<h1 className="text-3xl font-semibold tracking-tight text-white mt-4">SurfSense</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-800/70 backdrop-blur-sm rounded-xl shadow-xl border border-gray-700 p-6">
|
<div className="bg-gray-800/70 backdrop-blur-sm rounded-xl shadow-xl border border-gray-700 p-6">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h2 className="text-xl font-medium text-white">Enter your API Key</h2>
|
<h2 className="text-xl font-medium text-white">Enter your API Key</h2>
|
||||||
<p className="text-gray-400 text-sm">
|
<p className="text-gray-400 text-sm">
|
||||||
Your API key connects this extension to the SurfSense.
|
Your API key connects this extension to the SurfSense.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="apiKey" className="text-sm font-medium text-gray-300">
|
<label htmlFor="apiKey" className="text-sm font-medium text-gray-300">
|
||||||
API Key
|
API Key
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="apiKey"
|
id="apiKey"
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
className="w-full px-3 py-2 bg-gray-900/50 border border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-teal-500 text-white placeholder:text-gray-500"
|
className="w-full px-3 py-2 bg-gray-900/50 border border-gray-700 rounded-md focus:outline-none focus:ring-2 focus:ring-teal-500 text-white placeholder:text-gray-500"
|
||||||
placeholder="Enter your API key"
|
placeholder="Enter your API key"
|
||||||
/>
|
/>
|
||||||
{error && (
|
{error && <p className="text-red-400 text-sm mt-1">{error}</p>}
|
||||||
<p className="text-red-400 text-sm mt-1">{error}</p>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full bg-teal-600 hover:bg-teal-500 text-white py-2 px-4 rounded-md transition-colors"
|
className="w-full bg-teal-600 hover:bg-teal-500 text-white py-2 px-4 rounded-md transition-colors"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Verifying...
|
Verifying...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Connect"
|
"Connect"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="text-center mt-4">
|
<div className="text-center mt-4">
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
Need an API key?{" "}
|
Need an API key?{" "}
|
||||||
<a
|
<a
|
||||||
href="https://www.surfsense.net"
|
href="https://www.surfsense.net"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-teal-400 hover:text-teal-300 hover:underline"
|
className="text-teal-400 hover:text-teal-300 hover:underline"
|
||||||
>
|
rel="noopener"
|
||||||
Sign up
|
>
|
||||||
</a>
|
Sign up
|
||||||
</p>
|
</a>
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default ApiKeyForm
|
export default ApiKeyForm;
|
||||||
|
|
|
||||||
|
|
@ -1,476 +1,478 @@
|
||||||
import React, { useEffect, useState } from "react";
|
import brain from "data-base64:~assets/brain.png";
|
||||||
import { useNavigate } from "react-router-dom"
|
import icon from "data-base64:~assets/icon.png";
|
||||||
import icon from "data-base64:~assets/icon.png"
|
import { sendToBackground } from "@plasmohq/messaging";
|
||||||
|
import { Storage } from "@plasmohq/storage";
|
||||||
|
import {
|
||||||
|
CrossCircledIcon,
|
||||||
|
DiscIcon,
|
||||||
|
ExitIcon,
|
||||||
|
FileIcon,
|
||||||
|
ReloadIcon,
|
||||||
|
UploadIcon,
|
||||||
|
} from "@radix-ui/react-icons";
|
||||||
import { convertHtmlToMarkdown } from "dom-to-semantic-markdown";
|
import { convertHtmlToMarkdown } from "dom-to-semantic-markdown";
|
||||||
import type { WebHistory } from "~utils/interfaces";
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { getRenderedHtml } from "~utils/commons";
|
import React, { useEffect, useState } from "react";
|
||||||
import Loading from "./Loading";
|
import { useNavigate } from "react-router-dom";
|
||||||
import brain from "data-base64:~assets/brain.png"
|
import { cn } from "~/lib/utils";
|
||||||
import { Storage } from "@plasmohq/storage"
|
import { Button } from "~/routes/ui/button";
|
||||||
import { sendToBackground } from "@plasmohq/messaging"
|
|
||||||
import { Check, ChevronsUpDown } from "lucide-react"
|
|
||||||
import { cn } from "~/lib/utils"
|
|
||||||
import { Button } from "~/routes/ui/button"
|
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
CommandGroup,
|
CommandGroup,
|
||||||
CommandInput,
|
CommandInput,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
} from "~/routes/ui/command"
|
} from "~/routes/ui/command";
|
||||||
import {
|
import { Popover, PopoverContent, PopoverTrigger } from "~/routes/ui/popover";
|
||||||
Popover,
|
import { Label } from "~routes/ui/label";
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "~/routes/ui/popover"
|
|
||||||
import { useToast } from "~routes/ui/use-toast";
|
import { useToast } from "~routes/ui/use-toast";
|
||||||
import {
|
import { getRenderedHtml } from "~utils/commons";
|
||||||
CircleIcon,
|
import type { WebHistory } from "~utils/interfaces";
|
||||||
CrossCircledIcon,
|
import Loading from "./Loading";
|
||||||
DiscIcon,
|
|
||||||
ExitIcon,
|
|
||||||
FileIcon,
|
|
||||||
ReloadIcon,
|
|
||||||
ResetIcon,
|
|
||||||
UploadIcon
|
|
||||||
} from "@radix-ui/react-icons"
|
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast();
|
||||||
const navigation = useNavigate()
|
const navigation = useNavigate();
|
||||||
const [noOfWebPages, setNoOfWebPages] = useState<number>(0);
|
const [noOfWebPages, setNoOfWebPages] = useState<number>(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [open, setOpen] = React.useState(false)
|
const [open, setOpen] = React.useState(false);
|
||||||
const [value, setValue] = React.useState<string>("")
|
const [value, setValue] = React.useState<string>("");
|
||||||
const [searchspaces, setSearchSpaces] = useState([])
|
const [searchspaces, setSearchSpaces] = useState([]);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkSearchSpaces = async () => {
|
const checkSearchSpaces = async () => {
|
||||||
const storage = new Storage({ area: "local" })
|
const storage = new Storage({ area: "local" });
|
||||||
const token = await storage.get('token');
|
const token = await storage.get("token");
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/searchspaces/`,
|
`${process.env.PLASMO_PUBLIC_BACKEND_URL}/api/v1/searchspaces/`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`
|
Authorization: `Bearer ${token}`,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Token verification failed");
|
throw new Error("Token verification failed");
|
||||||
} else {
|
} else {
|
||||||
const res = await response.json()
|
const res = await response.json();
|
||||||
console.log(res)
|
console.log(res);
|
||||||
setSearchSpaces(res)
|
setSearchSpaces(res);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await storage.remove('token');
|
await storage.remove("token");
|
||||||
await storage.remove('showShadowDom');
|
await storage.remove("showShadowDom");
|
||||||
navigation("/login")
|
navigation("/login");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
checkSearchSpaces();
|
checkSearchSpaces();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function onLoad() {
|
||||||
|
try {
|
||||||
|
chrome.storage.onChanged.addListener((changes: any, areaName: string) => {
|
||||||
|
if (changes.webhistory) {
|
||||||
|
const webhistory = JSON.parse(changes.webhistory.newValue);
|
||||||
|
console.log("webhistory", webhistory);
|
||||||
|
|
||||||
useEffect(() => {
|
let sum = 0;
|
||||||
async function onLoad() {
|
webhistory.webhistory.forEach((element: any) => {
|
||||||
try {
|
sum = sum + element.tabHistory.length;
|
||||||
chrome.storage.onChanged.addListener(
|
});
|
||||||
(changes: any, areaName: string) => {
|
|
||||||
if (changes.webhistory) {
|
|
||||||
const webhistory = JSON.parse(changes.webhistory.newValue);
|
|
||||||
console.log("webhistory", webhistory)
|
|
||||||
|
|
||||||
let sum = 0
|
setNoOfWebPages(sum);
|
||||||
webhistory.webhistory.forEach((element: any) => {
|
}
|
||||||
sum = sum + element.tabHistory.length
|
});
|
||||||
});
|
|
||||||
|
|
||||||
setNoOfWebPages(sum)
|
const storage = new Storage({ area: "local" });
|
||||||
}
|
const searchspace = await storage.get("search_space");
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const storage = new Storage({ area: "local" })
|
if (searchspace) {
|
||||||
const searchspace = await storage.get("search_space");
|
setValue(searchspace);
|
||||||
|
}
|
||||||
|
|
||||||
if(searchspace){
|
await storage.set("showShadowDom", true);
|
||||||
setValue(searchspace)
|
|
||||||
}
|
|
||||||
|
|
||||||
await storage.set("showShadowDom", true)
|
const webhistoryObj: any = await storage.get("webhistory");
|
||||||
|
if (webhistoryObj.webhistory.length) {
|
||||||
|
const webhistory = webhistoryObj.webhistory;
|
||||||
|
|
||||||
const webhistoryObj: any = await storage.get("webhistory");
|
if (webhistoryObj) {
|
||||||
if (webhistoryObj.webhistory.length) {
|
let sum = 0;
|
||||||
const webhistory = webhistoryObj.webhistory;
|
webhistory.forEach((element: any) => {
|
||||||
|
sum = sum + element.tabHistory.length;
|
||||||
|
});
|
||||||
|
setNoOfWebPages(sum);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setNoOfWebPages(0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (webhistoryObj) {
|
onLoad();
|
||||||
let sum = 0
|
}, []);
|
||||||
webhistory.forEach((element: any) => {
|
|
||||||
sum = sum + element.tabHistory.length
|
|
||||||
});
|
|
||||||
setNoOfWebPages(sum)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setNoOfWebPages(0)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onLoad()
|
async function clearMem(): Promise<void> {
|
||||||
}, []);
|
try {
|
||||||
|
const storage = new Storage({ area: "local" });
|
||||||
|
|
||||||
async function clearMem(): Promise<void> {
|
const webHistory: any = await storage.get("webhistory");
|
||||||
try {
|
const urlQueue: any = await storage.get("urlQueueList");
|
||||||
const storage = new Storage({ area: "local" })
|
const timeQueue: any = await storage.get("timeQueueList");
|
||||||
|
|
||||||
let webHistory: any = await storage.get("webhistory");
|
|
||||||
let urlQueue: any = await storage.get("urlQueueList");
|
|
||||||
let timeQueue: any = await storage.get("timeQueueList");
|
|
||||||
|
|
||||||
if (!webHistory.webhistory) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
//Main Cleanup COde
|
|
||||||
chrome.tabs.query({}, async (tabs) => {
|
|
||||||
//Get Active Tabs Ids
|
|
||||||
let actives = tabs.map((tab) => {
|
|
||||||
if (tab.id) {
|
|
||||||
return tab.id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
actives = actives.filter((item: any) => item)
|
|
||||||
|
|
||||||
//Only retain which is still active
|
|
||||||
const newHistory = webHistory.webhistory.map((element: any) => {
|
|
||||||
//@ts-ignore
|
|
||||||
if (actives.includes(element.tabsessionId)) {
|
|
||||||
return element
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const newUrlQueue = urlQueue.urlQueueList.map((element: any) => {
|
|
||||||
//@ts-ignore
|
|
||||||
if (actives.includes(element.tabsessionId)) {
|
|
||||||
return element
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const newTimeQueue = timeQueue.timeQueueList.map((element: any) => {
|
|
||||||
//@ts-ignore
|
|
||||||
if (actives.includes(element.tabsessionId)) {
|
|
||||||
return element
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await storage.set("webhistory", { webhistory: newHistory.filter((item: any) => item) });
|
|
||||||
await storage.set("urlQueueList", { urlQueueList: newUrlQueue.filter((item: any) => item) });
|
|
||||||
await storage.set("timeQueueList", { timeQueueList: newTimeQueue.filter((item: any) => item) });
|
|
||||||
toast({
|
|
||||||
title: "History store cleared",
|
|
||||||
description: "Inactive history sessions have been removed",
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCurrSnapShot(): Promise<void> {
|
if (!webHistory.webhistory) {
|
||||||
chrome.tabs.query({ active: true, currentWindow: true }, async function (tabs) {
|
return;
|
||||||
const storage = new Storage({ area: "local" })
|
}
|
||||||
const tab = tabs[0];
|
|
||||||
if (tab.id) {
|
|
||||||
const tabId: number = tab.id
|
|
||||||
const result = await chrome.scripting.executeScript({
|
|
||||||
// @ts-ignore
|
|
||||||
target: { tabId: tab.id },
|
|
||||||
// @ts-ignore
|
|
||||||
func: getRenderedHtml,
|
|
||||||
});
|
|
||||||
|
|
||||||
let toPushInTabHistory: any = result[0].result;
|
//Main Cleanup COde
|
||||||
|
chrome.tabs.query({}, async (tabs) => {
|
||||||
|
//Get Active Tabs Ids
|
||||||
|
let actives = tabs.map((tab) => {
|
||||||
|
if (tab.id) {
|
||||||
|
return tab.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
//Updates 'tabhistory'
|
actives = actives.filter((item: any) => item);
|
||||||
let webhistoryObj: any = await storage.get("webhistory");
|
|
||||||
|
|
||||||
const webHistoryOfTabId = webhistoryObj.webhistory.filter(
|
//Only retain which is still active
|
||||||
(data: WebHistory) => {
|
const newHistory = webHistory.webhistory.map((element: any) => {
|
||||||
return data.tabsessionId === tab.id;
|
//@ts-ignore
|
||||||
}
|
if (actives.includes(element.tabsessionId)) {
|
||||||
);
|
return element;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
toPushInTabHistory.pageContentMarkdown = convertHtmlToMarkdown(
|
const newUrlQueue = urlQueue.urlQueueList.map((element: any) => {
|
||||||
toPushInTabHistory.renderedHtml,
|
//@ts-ignore
|
||||||
{
|
if (actives.includes(element.tabsessionId)) {
|
||||||
extractMainContent: true,
|
return element;
|
||||||
includeMetaData: false,
|
}
|
||||||
enableTableColumnTracking: true
|
});
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
delete toPushInTabHistory.renderedHtml
|
const newTimeQueue = timeQueue.timeQueueList.map((element: any) => {
|
||||||
|
//@ts-ignore
|
||||||
|
if (actives.includes(element.tabsessionId)) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let tabhistory = webHistoryOfTabId[0].tabHistory;
|
await storage.set("webhistory", { webhistory: newHistory.filter((item: any) => item) });
|
||||||
|
await storage.set("urlQueueList", {
|
||||||
|
urlQueueList: newUrlQueue.filter((item: any) => item),
|
||||||
|
});
|
||||||
|
await storage.set("timeQueueList", {
|
||||||
|
timeQueueList: newTimeQueue.filter((item: any) => item),
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: "History store cleared",
|
||||||
|
description: "Inactive history sessions have been removed",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const urlQueueListObj: any = await storage.get("urlQueueList");
|
async function saveCurrSnapShot(): Promise<void> {
|
||||||
const timeQueueListObj: any = await storage.get("timeQueueList");
|
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
|
||||||
|
const storage = new Storage({ area: "local" });
|
||||||
|
const tab = tabs[0];
|
||||||
|
if (tab.id) {
|
||||||
|
const tabId: number = tab.id;
|
||||||
|
const result = await chrome.scripting.executeScript({
|
||||||
|
// @ts-ignore
|
||||||
|
target: { tabId: tab.id },
|
||||||
|
// @ts-ignore
|
||||||
|
func: getRenderedHtml,
|
||||||
|
});
|
||||||
|
|
||||||
const isUrlQueueThere = urlQueueListObj.urlQueueList.find((data: WebHistory) => data.tabsessionId === tabId)
|
const toPushInTabHistory: any = result[0].result;
|
||||||
const isTimeQueueThere = timeQueueListObj.timeQueueList.find((data: WebHistory) => data.tabsessionId === tabId)
|
|
||||||
|
|
||||||
toPushInTabHistory.duration = toPushInTabHistory.entryTime - isTimeQueueThere.timeQueue[isTimeQueueThere.timeQueue.length - 1]
|
//Updates 'tabhistory'
|
||||||
if (isUrlQueueThere.urlQueue.length == 1) {
|
const webhistoryObj: any = await storage.get("webhistory");
|
||||||
toPushInTabHistory.reffererUrl = 'START'
|
|
||||||
}
|
|
||||||
if (isUrlQueueThere.urlQueue.length > 1) {
|
|
||||||
toPushInTabHistory.reffererUrl = isUrlQueueThere.urlQueue[isUrlQueueThere.urlQueue.length - 2];
|
|
||||||
}
|
|
||||||
|
|
||||||
webHistoryOfTabId[0].tabHistory.push(toPushInTabHistory);
|
const webHistoryOfTabId = webhistoryObj.webhistory.filter((data: WebHistory) => {
|
||||||
|
return data.tabsessionId === tab.id;
|
||||||
await storage.set("webhistory", webhistoryObj);
|
});
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Snapshot saved",
|
|
||||||
description: `Captured: ${toPushInTabHistory.title}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
toPushInTabHistory.pageContentMarkdown = convertHtmlToMarkdown(
|
||||||
}
|
toPushInTabHistory.renderedHtml,
|
||||||
|
{
|
||||||
|
extractMainContent: true,
|
||||||
|
includeMetaData: false,
|
||||||
|
enableTableColumnTracking: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const saveDatamessage = async () => {
|
delete toPushInTabHistory.renderedHtml;
|
||||||
if (value === "") {
|
|
||||||
toast({
|
|
||||||
title: "Select a SearchSpace !",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const storage = new Storage({ area: "local" })
|
|
||||||
const search_space_id = await storage.get("search_space_id");
|
|
||||||
|
|
||||||
if (!search_space_id) {
|
|
||||||
toast({
|
|
||||||
title: "Invalid SearchSpace selected!",
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(true);
|
const tabhistory = webHistoryOfTabId[0].tabHistory;
|
||||||
toast({
|
|
||||||
title: "Save job running",
|
|
||||||
description: "Saving captured content to SurfSense",
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
const urlQueueListObj: any = await storage.get("urlQueueList");
|
||||||
const resp = await sendToBackground({
|
const timeQueueListObj: any = await storage.get("timeQueueList");
|
||||||
// @ts-ignore
|
|
||||||
name: "savedata",
|
|
||||||
})
|
|
||||||
|
|
||||||
toast({
|
const isUrlQueueThere = urlQueueListObj.urlQueueList.find(
|
||||||
title: resp.message,
|
(data: WebHistory) => data.tabsessionId === tabId
|
||||||
})
|
);
|
||||||
} catch (error) {
|
const isTimeQueueThere = timeQueueListObj.timeQueueList.find(
|
||||||
toast({
|
(data: WebHistory) => data.tabsessionId === tabId
|
||||||
title: "Error saving data",
|
);
|
||||||
description: "Please try again",
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logOut(): Promise<void> {
|
toPushInTabHistory.duration =
|
||||||
const storage = new Storage({ area: "local" })
|
toPushInTabHistory.entryTime -
|
||||||
await storage.remove('token');
|
isTimeQueueThere.timeQueue[isTimeQueueThere.timeQueue.length - 1];
|
||||||
await storage.remove('showShadowDom');
|
if (isUrlQueueThere.urlQueue.length === 1) {
|
||||||
navigation("/login")
|
toPushInTabHistory.reffererUrl = "START";
|
||||||
}
|
}
|
||||||
|
if (isUrlQueueThere.urlQueue.length > 1) {
|
||||||
|
toPushInTabHistory.reffererUrl =
|
||||||
|
isUrlQueueThere.urlQueue[isUrlQueueThere.urlQueue.length - 2];
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
webHistoryOfTabId[0].tabHistory.push(toPushInTabHistory);
|
||||||
return <Loading />;
|
|
||||||
} else {
|
|
||||||
return searchspaces.length === 0 ? (
|
|
||||||
<div className="flex min-h-screen flex-col bg-gradient-to-br from-gray-900 to-gray-800">
|
|
||||||
<div className="flex flex-1 items-center justify-center p-4">
|
|
||||||
<div className="w-full max-w-md space-y-8">
|
|
||||||
<div className="flex flex-col items-center space-y-2 text-center">
|
|
||||||
<div className="rounded-full bg-gray-800 p-3 shadow-lg ring-2 ring-gray-700">
|
|
||||||
<img className="h-12 w-12" src={icon} alt="SurfSense" />
|
|
||||||
</div>
|
|
||||||
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-white">SurfSense</h1>
|
|
||||||
<div className="mt-4 rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-4 text-yellow-300">
|
|
||||||
<p className="text-sm">Please create a Search Space to continue</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex justify-center">
|
|
||||||
<Button
|
|
||||||
onClick={logOut}
|
|
||||||
variant="outline"
|
|
||||||
className="flex items-center space-x-2 border-gray-700 bg-gray-800 text-gray-200 hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
<ExitIcon className="h-4 w-4" />
|
|
||||||
<span>Sign Out</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex min-h-screen flex-col bg-gradient-to-br from-gray-900 to-gray-800">
|
|
||||||
<div className="container mx-auto max-w-md p-4">
|
|
||||||
<div className="flex items-center justify-between border-b border-gray-700 pb-4">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="rounded-full bg-gray-800 p-2 shadow-md ring-1 ring-gray-700">
|
|
||||||
<img className="h-6 w-6" src={icon} alt="SurfSense" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-xl font-semibold text-white">SurfSense</h1>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={logOut}
|
|
||||||
className="rounded-full text-gray-400 hover:bg-gray-800 hover:text-white"
|
|
||||||
>
|
|
||||||
<ExitIcon className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Log out</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 py-4">
|
await storage.set("webhistory", webhistoryObj);
|
||||||
<div className="flex flex-col items-center justify-center rounded-lg border border-gray-700 bg-gray-800/50 p-6 backdrop-blur-sm">
|
|
||||||
<div className="flex h-28 w-28 items-center justify-center rounded-full bg-gradient-to-br from-gray-700 to-gray-800 shadow-inner">
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<img className="mb-2 h-10 w-10 opacity-80" src={brain} alt="brain" />
|
|
||||||
<span className="text-2xl font-semibold text-white">{noOfWebPages}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mt-4 text-sm text-gray-400">Captured web pages</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-4 backdrop-blur-sm">
|
toast({
|
||||||
<label className="mb-2 block text-sm font-medium text-gray-300">
|
title: "Snapshot saved",
|
||||||
Search Space
|
description: `Captured: ${toPushInTabHistory.title}`,
|
||||||
</label>
|
});
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
}
|
||||||
<PopoverTrigger asChild>
|
});
|
||||||
<Button
|
}
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={open}
|
|
||||||
className="w-full justify-between border-gray-700 bg-gray-900 text-white hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
{value
|
|
||||||
? searchspaces.find((space) => space.name === value)?.name
|
|
||||||
: "Select Search Space..."}
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-full border-gray-700 bg-gray-800/90 p-0 backdrop-blur-sm">
|
|
||||||
<Command className="bg-transparent">
|
|
||||||
<CommandInput placeholder="Search spaces..." className="border-gray-700 bg-gray-900 text-gray-200" />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No search spaces found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{searchspaces.map((space) => (
|
|
||||||
<CommandItem
|
|
||||||
key={space.name}
|
|
||||||
value={space.name}
|
|
||||||
onSelect={async (currentValue) => {
|
|
||||||
const storage = new Storage({ area: "local" })
|
|
||||||
if (currentValue === value) {
|
|
||||||
await storage.set("search_space", "");
|
|
||||||
await storage.set("search_space_id", 0);
|
|
||||||
} else {
|
|
||||||
const selectedSpace = searchspaces.find((space) => space.name === currentValue);
|
|
||||||
await storage.set("search_space", currentValue);
|
|
||||||
await storage.set("search_space_id", selectedSpace.id);
|
|
||||||
}
|
|
||||||
setValue(currentValue === value ? "" : currentValue)
|
|
||||||
setOpen(false)
|
|
||||||
}}
|
|
||||||
className="aria-selected:bg-gray-700"
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
value === space.name ? "opacity-100" : "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<DiscIcon className="mr-2 h-4 w-4 text-teal-400" />
|
|
||||||
{space.name}
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3">
|
const saveDatamessage = async () => {
|
||||||
<Button
|
if (value === "") {
|
||||||
variant="destructive"
|
toast({
|
||||||
className="group flex w-full items-center justify-center space-x-2 bg-red-500/90 text-white hover:bg-red-600"
|
title: "Select a SearchSpace !",
|
||||||
onClick={() => clearMem()}
|
});
|
||||||
>
|
return;
|
||||||
<CrossCircledIcon className="h-4 w-4 transition-transform group-hover:scale-110" />
|
}
|
||||||
<span>Clear Inactive History</span>
|
|
||||||
</Button>
|
const storage = new Storage({ area: "local" });
|
||||||
|
const search_space_id = await storage.get("search_space_id");
|
||||||
<Button
|
|
||||||
variant="outline"
|
if (!search_space_id) {
|
||||||
className="group flex w-full items-center justify-center space-x-2 border-amber-500/50 bg-amber-500/10 text-amber-200 hover:bg-amber-500/20"
|
toast({
|
||||||
onClick={() => saveCurrSnapShot()}
|
title: "Invalid SearchSpace selected!",
|
||||||
>
|
variant: "destructive",
|
||||||
<FileIcon className="h-4 w-4 transition-transform group-hover:scale-110" />
|
});
|
||||||
<span>Save Current Page</span>
|
return;
|
||||||
</Button>
|
}
|
||||||
|
|
||||||
<Button
|
setIsSaving(true);
|
||||||
variant="default"
|
toast({
|
||||||
className="group flex w-full items-center justify-center space-x-2 bg-gradient-to-r from-teal-500 to-emerald-500 text-white transition-all hover:from-teal-600 hover:to-emerald-600"
|
title: "Save job running",
|
||||||
onClick={() => saveDatamessage()}
|
description: "Saving captured content to SurfSense",
|
||||||
disabled={isSaving}
|
});
|
||||||
>
|
|
||||||
{isSaving ? (
|
try {
|
||||||
<>
|
const resp = await sendToBackground({
|
||||||
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
// @ts-ignore
|
||||||
<span>Saving to SurfSense...</span>
|
name: "savedata",
|
||||||
</>
|
});
|
||||||
) : (
|
|
||||||
<>
|
toast({
|
||||||
<UploadIcon className="h-4 w-4 transition-transform group-hover:scale-110" />
|
title: resp.message,
|
||||||
<span>Save to SurfSense</span>
|
});
|
||||||
</>
|
} catch (error) {
|
||||||
)}
|
toast({
|
||||||
</Button>
|
title: "Error saving data",
|
||||||
</div>
|
description: "Please try again",
|
||||||
</div>
|
variant: "destructive",
|
||||||
</div>
|
});
|
||||||
</div>
|
} finally {
|
||||||
);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function logOut(): Promise<void> {
|
||||||
|
const storage = new Storage({ area: "local" });
|
||||||
|
await storage.remove("token");
|
||||||
|
await storage.remove("showShadowDom");
|
||||||
|
navigation("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loading />;
|
||||||
|
} else {
|
||||||
|
return searchspaces.length === 0 ? (
|
||||||
|
<div className="flex min-h-screen flex-col bg-gradient-to-br from-gray-900 to-gray-800">
|
||||||
|
<div className="flex flex-1 items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md space-y-8">
|
||||||
|
<div className="flex flex-col items-center space-y-2 text-center">
|
||||||
|
<div className="rounded-full bg-gray-800 p-3 shadow-lg ring-2 ring-gray-700">
|
||||||
|
<img className="h-12 w-12" src={icon} alt="SurfSense" />
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-white">SurfSense</h1>
|
||||||
|
<div className="mt-4 rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-4 text-yellow-300">
|
||||||
|
<p className="text-sm">Please create a Search Space to continue</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-center">
|
||||||
|
<Button
|
||||||
|
onClick={logOut}
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center space-x-2 border-gray-700 bg-gray-800 text-gray-200 hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<ExitIcon className="h-4 w-4" />
|
||||||
|
<span>Sign Out</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-screen flex-col bg-gradient-to-br from-gray-900 to-gray-800">
|
||||||
|
<div className="container mx-auto max-w-md p-4">
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-700 pb-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="rounded-full bg-gray-800 p-2 shadow-md ring-1 ring-gray-700">
|
||||||
|
<img className="h-6 w-6" src={icon} alt="SurfSense" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">SurfSense</h1>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={logOut}
|
||||||
|
className="rounded-full text-gray-400 hover:bg-gray-800 hover:text-white"
|
||||||
|
>
|
||||||
|
<ExitIcon className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Log out</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 py-4">
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-lg border border-gray-700 bg-gray-800/50 p-6 backdrop-blur-sm">
|
||||||
|
<div className="flex h-28 w-28 items-center justify-center rounded-full bg-gradient-to-br from-gray-700 to-gray-800 shadow-inner">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<img className="mb-2 h-10 w-10 opacity-80" src={brain} alt="brain" />
|
||||||
|
<span className="text-2xl font-semibold text-white">{noOfWebPages}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm text-gray-400">Captured web pages</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-gray-700 bg-gray-800/50 p-4 backdrop-blur-sm">
|
||||||
|
<Label className="mb-2 block text-sm font-medium text-gray-300">Search Space</Label>
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-full justify-between border-gray-700 bg-gray-900 text-white hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{value
|
||||||
|
? searchspaces.find((space) => space.name === value)?.name
|
||||||
|
: "Select Search Space..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full border-gray-700 bg-gray-800/90 p-0 backdrop-blur-sm">
|
||||||
|
<Command className="bg-transparent">
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search spaces..."
|
||||||
|
className="border-gray-700 bg-gray-900 text-gray-200"
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No search spaces found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{searchspaces.map((space) => (
|
||||||
|
<CommandItem
|
||||||
|
key={space.name}
|
||||||
|
value={space.name}
|
||||||
|
onSelect={async (currentValue) => {
|
||||||
|
const storage = new Storage({ area: "local" });
|
||||||
|
if (currentValue === value) {
|
||||||
|
await storage.set("search_space", "");
|
||||||
|
await storage.set("search_space_id", 0);
|
||||||
|
} else {
|
||||||
|
const selectedSpace = searchspaces.find(
|
||||||
|
(space) => space.name === currentValue
|
||||||
|
);
|
||||||
|
await storage.set("search_space", currentValue);
|
||||||
|
await storage.set("search_space_id", selectedSpace.id);
|
||||||
|
}
|
||||||
|
setValue(currentValue === value ? "" : currentValue);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="aria-selected:bg-gray-700"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
value === space.name ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DiscIcon className="mr-2 h-4 w-4 text-teal-400" />
|
||||||
|
{space.name}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="group flex w-full items-center justify-center space-x-2 bg-red-500/90 text-white hover:bg-red-600"
|
||||||
|
onClick={() => clearMem()}
|
||||||
|
>
|
||||||
|
<CrossCircledIcon className="h-4 w-4 transition-transform group-hover:scale-110" />
|
||||||
|
<span>Clear Inactive History</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="group flex w-full items-center justify-center space-x-2 border-amber-500/50 bg-amber-500/10 text-amber-200 hover:bg-amber-500/20"
|
||||||
|
onClick={() => saveCurrSnapShot()}
|
||||||
|
>
|
||||||
|
<FileIcon className="h-4 w-4 transition-transform group-hover:scale-110" />
|
||||||
|
<span>Save Current Page</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="group flex w-full items-center justify-center space-x-2 bg-gradient-to-r from-teal-500 to-emerald-500 text-white transition-all hover:from-teal-600 hover:to-emerald-600"
|
||||||
|
onClick={() => saveDatamessage()}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
<span>Saving to SurfSense...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UploadIcon className="h-4 w-4 transition-transform group-hover:scale-110" />
|
||||||
|
<span>Save to SurfSense</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HomePage
|
export default HomePage;
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,37 @@
|
||||||
import React from 'react'
|
import icon from "data-base64:~assets/icon.png";
|
||||||
import icon from "data-base64:~assets/icon.png"
|
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||||
import { ReloadIcon } from "@radix-ui/react-icons"
|
|
||||||
|
|
||||||
const Loading = () => {
|
const Loading = () => {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-gray-900 to-gray-800">
|
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-br from-gray-900 to-gray-800">
|
||||||
<div className="w-full max-w-md mx-auto space-y-8">
|
<div className="w-full max-w-md mx-auto space-y-8">
|
||||||
<div className="flex flex-col items-center space-y-2">
|
<div className="flex flex-col items-center space-y-2">
|
||||||
<div className="bg-gray-800 p-3 rounded-full ring-2 ring-gray-700 shadow-lg">
|
<div className="bg-gray-800 p-3 rounded-full ring-2 ring-gray-700 shadow-lg">
|
||||||
<img className="w-12 h-12" src={icon} alt="SurfSense" />
|
<img className="w-12 h-12" src={icon} alt="SurfSense" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-semibold tracking-tight text-white mt-4">SurfSense</h1>
|
<h1 className="text-3xl font-semibold tracking-tight text-white mt-4">SurfSense</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center mt-8">
|
|
||||||
<ReloadIcon className="h-10 w-10 text-teal-400 animate-spin" />
|
|
||||||
<div className="mt-6 text-lg text-gray-300 flex space-x-1">
|
|
||||||
{Array.from("LOADING").map((letter, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className="inline-block animate-pulse text-teal-400"
|
|
||||||
style={{
|
|
||||||
animationDelay: `${i * 0.1}s`,
|
|
||||||
animationDuration: '1.5s'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{letter}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Loading
|
<div className="flex flex-col items-center mt-8">
|
||||||
|
<ReloadIcon className="h-10 w-10 text-teal-400 animate-spin" />
|
||||||
|
<div className="mt-6 text-lg text-gray-300 flex space-x-1">
|
||||||
|
{Array.from("LOADING").map((letter, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="inline-block animate-pulse text-teal-400"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${i * 0.1}s`,
|
||||||
|
animationDuration: "1.5s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Loading;
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,49 @@
|
||||||
import * as React from "react"
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
destructive:
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
outline:
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
secondary:
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
},
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
size: {
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
default: "h-10 px-4 py-2",
|
||||||
},
|
sm: "h-9 rounded-md px-3",
|
||||||
size: {
|
lg: "h-11 rounded-md px-8",
|
||||||
default: "h-10 px-4 py-2",
|
icon: "h-10 w-10",
|
||||||
sm: "h-9 rounded-md px-3",
|
},
|
||||||
lg: "h-11 rounded-md px-8",
|
},
|
||||||
icon: "h-10 w-10",
|
defaultVariants: {
|
||||||
},
|
variant: "default",
|
||||||
},
|
size: "default",
|
||||||
defaultVariants: {
|
},
|
||||||
variant: "default",
|
}
|
||||||
size: "default",
|
);
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean
|
asChild?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button";
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
);
|
||||||
ref={ref}
|
}
|
||||||
{...props}
|
);
|
||||||
/>
|
Button.displayName = "Button";
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Button.displayName = "Button"
|
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants };
|
||||||
|
|
|
||||||
|
|
@ -1,155 +1,145 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import type { DialogProps } from "@radix-ui/react-dialog";
|
||||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
import { Command as CommandPrimitive } from "cmdk"
|
import { Search } from "lucide-react";
|
||||||
import { Search } from "lucide-react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
import { Dialog, DialogContent } from "~/routes/ui/dialog"
|
import { Dialog, DialogContent } from "~/routes/ui/dialog";
|
||||||
|
|
||||||
const Command = React.forwardRef<
|
const Command = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive>,
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<CommandPrimitive
|
<CommandPrimitive
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
Command.displayName = CommandPrimitive.displayName
|
Command.displayName = CommandPrimitive.displayName;
|
||||||
|
|
||||||
interface CommandDialogProps extends DialogProps {}
|
interface CommandDialogProps extends DialogProps {}
|
||||||
|
|
||||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
{children}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
const CommandInput = React.forwardRef<
|
const CommandInput = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))
|
));
|
||||||
|
|
||||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||||
|
|
||||||
const CommandList = React.forwardRef<
|
const CommandList = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.List>,
|
React.ElementRef<typeof CommandPrimitive.List>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
|
|
||||||
CommandList.displayName = CommandPrimitive.List.displayName
|
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||||
|
|
||||||
const CommandEmpty = React.forwardRef<
|
const CommandEmpty = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
>((props, ref) => (
|
>((props, ref) => (
|
||||||
<CommandPrimitive.Empty
|
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
|
||||||
ref={ref}
|
));
|
||||||
className="py-6 text-center text-sm"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||||
|
|
||||||
const CommandGroup = React.forwardRef<
|
const CommandGroup = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<CommandPrimitive.Group
|
<CommandPrimitive.Group
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
|
|
||||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||||
|
|
||||||
const CommandSeparator = React.forwardRef<
|
const CommandSeparator = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<CommandPrimitive.Separator
|
<CommandPrimitive.Separator
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("-mx-1 h-px bg-border", className)}
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||||
|
|
||||||
const CommandItem = React.forwardRef<
|
const CommandItem = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
|
|
||||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||||
|
|
||||||
const CommandShortcut = ({
|
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
className,
|
return (
|
||||||
...props
|
<span
|
||||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||||
return (
|
{...props}
|
||||||
<span
|
/>
|
||||||
className={cn(
|
);
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
};
|
||||||
className
|
CommandShortcut.displayName = "CommandShortcut";
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
CommandShortcut.displayName = "CommandShortcut"
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Command,
|
Command,
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
CommandInput,
|
CommandInput,
|
||||||
CommandList,
|
CommandList,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
CommandGroup,
|
CommandGroup,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
CommandShortcut,
|
CommandShortcut,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,122 +1,104 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import { X } from "lucide-react";
|
||||||
import { X } from "lucide-react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
const DialogTrigger = DialogPrimitive.Trigger
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
const DialogPortal = DialogPrimitive.Portal
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
const DialogClose = DialogPrimitive.Close
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
const DialogOverlay = React.forwardRef<
|
const DialogOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
))
|
));
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
const DialogHeader = ({
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
className,
|
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||||
...props
|
);
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
DialogHeader.displayName = "DialogHeader";
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
DialogHeader.displayName = "DialogHeader"
|
|
||||||
|
|
||||||
const DialogFooter = ({
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
className,
|
<div
|
||||||
...props
|
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
{...props}
|
||||||
<div
|
/>
|
||||||
className={cn(
|
);
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
DialogFooter.displayName = "DialogFooter";
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
DialogFooter.displayName = "DialogFooter"
|
|
||||||
|
|
||||||
const DialogTitle = React.forwardRef<
|
const DialogTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||||
"text-lg font-semibold leading-none tracking-tight",
|
{...props}
|
||||||
className
|
/>
|
||||||
)}
|
));
|
||||||
{...props}
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
/>
|
|
||||||
))
|
|
||||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
|
||||||
|
|
||||||
const DialogDescription = React.forwardRef<
|
const DialogDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogOverlay,
|
DialogOverlay,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
}
|
};
|
||||||
|
|
|
||||||
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<
|
const PopoverContent = React.forwardRef<
|
||||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
<PopoverPrimitive.Portal>
|
<PopoverPrimitive.Portal>
|
||||||
<PopoverPrimitive.Content
|
<PopoverPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
align={align}
|
align={align}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</PopoverPrimitive.Portal>
|
</PopoverPrimitive.Portal>
|
||||||
))
|
));
|
||||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Popover, PopoverTrigger, PopoverContent }
|
export { Popover, PopoverTrigger, PopoverContent };
|
||||||
|
|
|
||||||
|
|
@ -1,129 +1,124 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import * as React from "react"
|
import * as ToastPrimitives from "@radix-ui/react-toast";
|
||||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { X } from "lucide-react";
|
||||||
import { X } from "lucide-react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const ToastProvider = ToastPrimitives.Provider
|
const ToastProvider = ToastPrimitives.Provider;
|
||||||
|
|
||||||
const ToastViewport = React.forwardRef<
|
const ToastViewport = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Viewport
|
<ToastPrimitives.Viewport
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||||
|
|
||||||
const toastVariants = cva(
|
const toastVariants = cva(
|
||||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "border bg-background text-foreground",
|
default: "border bg-background text-foreground",
|
||||||
destructive:
|
destructive:
|
||||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
const Toast = React.forwardRef<
|
const Toast = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||||
VariantProps<typeof toastVariants>
|
|
||||||
>(({ className, variant, ...props }, ref) => {
|
>(({ className, variant, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<ToastPrimitives.Root
|
<ToastPrimitives.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(toastVariants({ variant }), className)}
|
className={cn(toastVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
})
|
});
|
||||||
Toast.displayName = ToastPrimitives.Root.displayName
|
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||||
|
|
||||||
const ToastAction = React.forwardRef<
|
const ToastAction = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Action
|
<ToastPrimitives.Action
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||||
|
|
||||||
const ToastClose = React.forwardRef<
|
const ToastClose = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Close
|
<ToastPrimitives.Close
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
toast-close=""
|
toast-close=""
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</ToastPrimitives.Close>
|
</ToastPrimitives.Close>
|
||||||
))
|
));
|
||||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||||
|
|
||||||
const ToastTitle = React.forwardRef<
|
const ToastTitle = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Title
|
<ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
|
||||||
ref={ref}
|
));
|
||||||
className={cn("text-sm font-semibold", className)}
|
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
|
||||||
|
|
||||||
const ToastDescription = React.forwardRef<
|
const ToastDescription = React.forwardRef<
|
||||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<ToastPrimitives.Description
|
<ToastPrimitives.Description
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-sm opacity-90", className)}
|
className={cn("text-sm opacity-90", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||||
|
|
||||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||||
|
|
||||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type ToastProps,
|
type ToastProps,
|
||||||
type ToastActionElement,
|
type ToastActionElement,
|
||||||
ToastProvider,
|
ToastProvider,
|
||||||
ToastViewport,
|
ToastViewport,
|
||||||
Toast,
|
Toast,
|
||||||
ToastTitle,
|
ToastTitle,
|
||||||
ToastDescription,
|
ToastDescription,
|
||||||
ToastClose,
|
ToastClose,
|
||||||
ToastAction,
|
ToastAction,
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,31 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useToast } from "@/routes/ui/use-toast"
|
|
||||||
import {
|
import {
|
||||||
Toast,
|
Toast,
|
||||||
ToastClose,
|
ToastClose,
|
||||||
ToastDescription,
|
ToastDescription,
|
||||||
ToastProvider,
|
ToastProvider,
|
||||||
ToastTitle,
|
ToastTitle,
|
||||||
ToastViewport,
|
ToastViewport,
|
||||||
} from "@/routes/ui/toast"
|
} from "@/routes/ui/toast";
|
||||||
|
import { useToast } from "@/routes/ui/use-toast";
|
||||||
|
|
||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
const { toasts } = useToast()
|
const { toasts } = useToast();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
{toasts.map(({ id, title, description, action, ...props }) => (
|
||||||
return (
|
<Toast key={id} {...props}>
|
||||||
<Toast key={id} {...props}>
|
<div className="grid gap-1">
|
||||||
<div className="grid gap-1">
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
{title && <ToastTitle>{title}</ToastTitle>}
|
{description && <ToastDescription>{description}</ToastDescription>}
|
||||||
{description && (
|
</div>
|
||||||
<ToastDescription>{description}</ToastDescription>
|
{action}
|
||||||
)}
|
<ToastClose />
|
||||||
</div>
|
</Toast>
|
||||||
{action}
|
))}
|
||||||
<ToastClose />
|
<ToastViewport />
|
||||||
</Toast>
|
</ToastProvider>
|
||||||
)
|
);
|
||||||
})}
|
|
||||||
<ToastViewport />
|
|
||||||
</ToastProvider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,194 +1,189 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
// Inspired by react-hot-toast library
|
// Inspired by react-hot-toast library
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import type {
|
import type { ToastActionElement, ToastProps } from "@/routes/ui/toast";
|
||||||
ToastActionElement,
|
|
||||||
ToastProps,
|
|
||||||
} from "@/routes/ui/toast"
|
|
||||||
|
|
||||||
const TOAST_LIMIT = 1
|
const TOAST_LIMIT = 1;
|
||||||
const TOAST_REMOVE_DELAY = 1000000
|
const TOAST_REMOVE_DELAY = 1000000;
|
||||||
|
|
||||||
type ToasterToast = ToastProps & {
|
type ToasterToast = ToastProps & {
|
||||||
id: string
|
id: string;
|
||||||
title?: React.ReactNode
|
title?: React.ReactNode;
|
||||||
description?: React.ReactNode
|
description?: React.ReactNode;
|
||||||
action?: ToastActionElement
|
action?: ToastActionElement;
|
||||||
}
|
};
|
||||||
|
|
||||||
const actionTypes = {
|
const actionTypes = {
|
||||||
ADD_TOAST: "ADD_TOAST",
|
ADD_TOAST: "ADD_TOAST",
|
||||||
UPDATE_TOAST: "UPDATE_TOAST",
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
DISMISS_TOAST: "DISMISS_TOAST",
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
REMOVE_TOAST: "REMOVE_TOAST",
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
} as const
|
} as const;
|
||||||
|
|
||||||
let count = 0
|
let count = 0;
|
||||||
|
|
||||||
function genId() {
|
function genId() {
|
||||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||||
return count.toString()
|
return count.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionType = typeof actionTypes
|
type ActionType = typeof actionTypes;
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| {
|
| {
|
||||||
type: ActionType["ADD_TOAST"]
|
type: ActionType["ADD_TOAST"];
|
||||||
toast: ToasterToast
|
toast: ToasterToast;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: ActionType["UPDATE_TOAST"]
|
type: ActionType["UPDATE_TOAST"];
|
||||||
toast: Partial<ToasterToast>
|
toast: Partial<ToasterToast>;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: ActionType["DISMISS_TOAST"]
|
type: ActionType["DISMISS_TOAST"];
|
||||||
toastId?: ToasterToast["id"]
|
toastId?: ToasterToast["id"];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: ActionType["REMOVE_TOAST"]
|
type: ActionType["REMOVE_TOAST"];
|
||||||
toastId?: ToasterToast["id"]
|
toastId?: ToasterToast["id"];
|
||||||
}
|
};
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
toasts: ToasterToast[]
|
toasts: ToasterToast[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
const addToRemoveQueue = (toastId: string) => {
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
if (toastTimeouts.has(toastId)) {
|
if (toastTimeouts.has(toastId)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
toastTimeouts.delete(toastId)
|
toastTimeouts.delete(toastId);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "REMOVE_TOAST",
|
type: "REMOVE_TOAST",
|
||||||
toastId: toastId,
|
toastId: toastId,
|
||||||
})
|
});
|
||||||
}, TOAST_REMOVE_DELAY)
|
}, TOAST_REMOVE_DELAY);
|
||||||
|
|
||||||
toastTimeouts.set(toastId, timeout)
|
toastTimeouts.set(toastId, timeout);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const reducer = (state: State, action: Action): State => {
|
export const reducer = (state: State, action: Action): State => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "ADD_TOAST":
|
case "ADD_TOAST":
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
}
|
};
|
||||||
|
|
||||||
case "UPDATE_TOAST":
|
case "UPDATE_TOAST":
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: state.toasts.map((t) =>
|
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
};
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
case "DISMISS_TOAST": {
|
case "DISMISS_TOAST": {
|
||||||
const { toastId } = action
|
const { toastId } = action;
|
||||||
|
|
||||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
// but I'll keep it here for simplicity
|
// but I'll keep it here for simplicity
|
||||||
if (toastId) {
|
if (toastId) {
|
||||||
addToRemoveQueue(toastId)
|
addToRemoveQueue(toastId);
|
||||||
} else {
|
} else {
|
||||||
state.toasts.forEach((toast) => {
|
state.toasts.forEach((toast) => {
|
||||||
addToRemoveQueue(toast.id)
|
addToRemoveQueue(toast.id);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: state.toasts.map((t) =>
|
toasts: state.toasts.map((t) =>
|
||||||
t.id === toastId || toastId === undefined
|
t.id === toastId || toastId === undefined
|
||||||
? {
|
? {
|
||||||
...t,
|
...t,
|
||||||
open: false,
|
open: false,
|
||||||
}
|
}
|
||||||
: t
|
: t
|
||||||
),
|
),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
case "REMOVE_TOAST":
|
case "REMOVE_TOAST":
|
||||||
if (action.toastId === undefined) {
|
if (action.toastId === undefined) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: [],
|
toasts: [],
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const listeners: Array<(state: State) => void> = []
|
const listeners: Array<(state: State) => void> = [];
|
||||||
|
|
||||||
let memoryState: State = { toasts: [] }
|
let memoryState: State = { toasts: [] };
|
||||||
|
|
||||||
function dispatch(action: Action) {
|
function dispatch(action: Action) {
|
||||||
memoryState = reducer(memoryState, action)
|
memoryState = reducer(memoryState, action);
|
||||||
listeners.forEach((listener) => {
|
listeners.forEach((listener) => {
|
||||||
listener(memoryState)
|
listener(memoryState);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type Toast = Omit<ToasterToast, "id">
|
type Toast = Omit<ToasterToast, "id">;
|
||||||
|
|
||||||
function toast({ ...props }: Toast) {
|
function toast({ ...props }: Toast) {
|
||||||
const id = genId()
|
const id = genId();
|
||||||
|
|
||||||
const update = (props: ToasterToast) =>
|
const update = (props: ToasterToast) =>
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UPDATE_TOAST",
|
type: "UPDATE_TOAST",
|
||||||
toast: { ...props, id },
|
toast: { ...props, id },
|
||||||
})
|
});
|
||||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "ADD_TOAST",
|
type: "ADD_TOAST",
|
||||||
toast: {
|
toast: {
|
||||||
...props,
|
...props,
|
||||||
id,
|
id,
|
||||||
open: true,
|
open: true,
|
||||||
onOpenChange: (open) => {
|
onOpenChange: (open) => {
|
||||||
if (!open) dismiss()
|
if (!open) dismiss();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: id,
|
id: id,
|
||||||
dismiss,
|
dismiss,
|
||||||
update,
|
update,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function useToast() {
|
function useToast() {
|
||||||
const [state, setState] = React.useState<State>(memoryState)
|
const [state, setState] = React.useState<State>(memoryState);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
listeners.push(setState)
|
listeners.push(setState);
|
||||||
return () => {
|
return () => {
|
||||||
const index = listeners.indexOf(setState)
|
const index = listeners.indexOf(setState);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
listeners.splice(index, 1)
|
listeners.splice(index, 1);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}, [state])
|
}, [state]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
toast,
|
toast,
|
||||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useToast, toast }
|
export { useToast, toast };
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,76 @@
|
||||||
const { fontFamily } = require("tailwindcss/defaultTheme")
|
const { fontFamily } = require("tailwindcss/defaultTheme");
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
content: ["./*.{js,jsx,ts,tsx}","./routes/*.tsx","./routes/**/*.tsx"],
|
content: ["./*.{js,jsx,ts,tsx}", "./routes/*.tsx", "./routes/**/*.tsx"],
|
||||||
theme: {
|
theme: {
|
||||||
container: {
|
container: {
|
||||||
center: true,
|
center: true,
|
||||||
padding: "2rem",
|
padding: "2rem",
|
||||||
screens: {
|
screens: {
|
||||||
"2xl": "1400px",
|
"2xl": "1400px",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
border: "hsl(var(--border))",
|
border: "hsl(var(--border))",
|
||||||
input: "hsl(var(--input))",
|
input: "hsl(var(--input))",
|
||||||
ring: "hsl(var(--ring))",
|
ring: "hsl(var(--ring))",
|
||||||
background: "hsl(var(--background))",
|
background: "hsl(var(--background))",
|
||||||
foreground: "hsl(var(--foreground))",
|
foreground: "hsl(var(--foreground))",
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: "hsl(var(--primary))",
|
DEFAULT: "hsl(var(--primary))",
|
||||||
foreground: "hsl(var(--primary-foreground))",
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: "hsl(var(--secondary))",
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
foreground: "hsl(var(--secondary-foreground))",
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
},
|
},
|
||||||
destructive: {
|
destructive: {
|
||||||
DEFAULT: "hsl(var(--destructive))",
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
foreground: "hsl(var(--destructive-foreground))",
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: "hsl(var(--muted))",
|
DEFAULT: "hsl(var(--muted))",
|
||||||
foreground: "hsl(var(--muted-foreground))",
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: "hsl(var(--accent))",
|
DEFAULT: "hsl(var(--accent))",
|
||||||
foreground: "hsl(var(--accent-foreground))",
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
},
|
},
|
||||||
popover: {
|
popover: {
|
||||||
DEFAULT: "hsl(var(--popover))",
|
DEFAULT: "hsl(var(--popover))",
|
||||||
foreground: "hsl(var(--popover-foreground))",
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
DEFAULT: "hsl(var(--card))",
|
DEFAULT: "hsl(var(--card))",
|
||||||
foreground: "hsl(var(--card-foreground))",
|
foreground: "hsl(var(--card-foreground))",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: `var(--radius)`,
|
lg: `var(--radius)`,
|
||||||
md: `calc(var(--radius) - 2px)`,
|
md: `calc(var(--radius) - 2px)`,
|
||||||
sm: "calc(var(--radius) - 4px)",
|
sm: "calc(var(--radius) - 4px)",
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ["var(--font-sans)", ...fontFamily.sans],
|
sans: ["var(--font-sans)", ...fontFamily.sans],
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
"accordion-down": {
|
"accordion-down": {
|
||||||
from: { height: "0" },
|
from: { height: "0" },
|
||||||
to: { height: "var(--radix-accordion-content-height)" },
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
},
|
},
|
||||||
"accordion-up": {
|
"accordion-up": {
|
||||||
from: { height: "var(--radix-accordion-content-height)" },
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
to: { height: "0" },
|
to: { height: "0" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [require("tailwindcss-animate")],
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,96 +3,97 @@
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 240 10% 3.9%;
|
--background: 240 10% 3.9%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
|
|
||||||
--card: 240 10% 3.9%;
|
|
||||||
--card-foreground: 0 0% 98%;
|
|
||||||
|
|
||||||
--popover: 240 10% 3.9%;
|
|
||||||
--popover-foreground: 0 0% 98%;
|
|
||||||
|
|
||||||
--primary: 180 100% 37%;
|
|
||||||
--primary-foreground: 0 0% 98%;
|
|
||||||
|
|
||||||
--secondary: 240 5.9% 10%;
|
|
||||||
--secondary-foreground: 0 0% 98%;
|
|
||||||
|
|
||||||
--muted: 240 5.9% 10%;
|
|
||||||
--muted-foreground: 240 5% 64.9%;
|
|
||||||
|
|
||||||
--accent: 169 97% 37%;
|
|
||||||
--accent-foreground: 0 0% 98%;
|
|
||||||
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
|
||||||
--destructive-foreground: 0 0% 98%;
|
|
||||||
|
|
||||||
--border: 240 5.9% 24%;
|
--card: 240 10% 3.9%;
|
||||||
--input: 240 5.9% 10%;
|
--card-foreground: 0 0% 98%;
|
||||||
--ring: 180 100% 37%;
|
|
||||||
|
|
||||||
--radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
--popover: 240 10% 3.9%;
|
||||||
--background: 224 71% 4%;
|
--popover-foreground: 0 0% 98%;
|
||||||
--foreground: 213 31% 91%;
|
|
||||||
|
|
||||||
--muted: 223 47% 11%;
|
--primary: 180 100% 37%;
|
||||||
--muted-foreground: 215.4 16.3% 56.9%;
|
--primary-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--accent: 216 34% 17%;
|
--secondary: 240 5.9% 10%;
|
||||||
--accent-foreground: 210 40% 98%;
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--popover: 224 71% 4%;
|
--muted: 240 5.9% 10%;
|
||||||
--popover-foreground: 215 20.2% 65.1%;
|
--muted-foreground: 240 5% 64.9%;
|
||||||
|
|
||||||
--border: 216 34% 17%;
|
--accent: 169 97% 37%;
|
||||||
--input: 216 34% 17%;
|
--accent-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--card: 224 71% 4%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--card-foreground: 213 31% 91%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
|
||||||
--primary: 210 40% 98%;
|
--border: 240 5.9% 24%;
|
||||||
--primary-foreground: 222.2 47.4% 1.2%;
|
--input: 240 5.9% 10%;
|
||||||
|
--ring: 180 100% 37%;
|
||||||
|
|
||||||
--secondary: 222.2 47.4% 11.2%;
|
--radius: 0.5rem;
|
||||||
--secondary-foreground: 210 40% 98%;
|
}
|
||||||
|
|
||||||
--destructive: 0 63% 31%;
|
.dark {
|
||||||
--destructive-foreground: 210 40% 98%;
|
--background: 224 71% 4%;
|
||||||
|
--foreground: 213 31% 91%;
|
||||||
|
|
||||||
--ring: 216 34% 17%;
|
--muted: 223 47% 11%;
|
||||||
|
--muted-foreground: 215.4 16.3% 56.9%;
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--accent: 216 34% 17%;
|
||||||
}
|
--accent-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--popover: 224 71% 4%;
|
||||||
|
--popover-foreground: 215 20.2% 65.1%;
|
||||||
|
|
||||||
|
--border: 216 34% 17%;
|
||||||
|
--input: 216 34% 17%;
|
||||||
|
|
||||||
|
--card: 224 71% 4%;
|
||||||
|
--card-foreground: 213 31% 91%;
|
||||||
|
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 1.2%;
|
||||||
|
|
||||||
|
--secondary: 222.2 47.4% 11.2%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 63% 31%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--ring: 216 34% 17%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-width: 380px;
|
min-width: 380px;
|
||||||
min-height: 580px;
|
min-height: 580px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
font-family:
|
||||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans",
|
||||||
}
|
"Helvetica Neue", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
/* Styling for shadcn/ui components */
|
/* Styling for shadcn/ui components */
|
||||||
.command-dialog {
|
.command-dialog {
|
||||||
@apply dark;
|
@apply dark;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Popup page dimensions */
|
/* Popup page dimensions */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
body {
|
body {
|
||||||
@apply bg-slate-950 text-white;
|
@apply bg-slate-950 text-white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,12 @@
|
||||||
{
|
{
|
||||||
"extends": "plasmo/templates/tsconfig.base",
|
"extends": "plasmo/templates/tsconfig.base.json",
|
||||||
"exclude": [
|
"exclude": ["node_modules"],
|
||||||
"node_modules"
|
"include": [".plasmo/index.d.ts", "./**/*.ts", "./**/*.tsx"],
|
||||||
],
|
"compilerOptions": {
|
||||||
"include": [
|
"paths": {
|
||||||
".plasmo/index.d.ts",
|
"~*": ["./*"],
|
||||||
"./**/*.ts",
|
"@/*": ["./*"]
|
||||||
"./**/*.tsx"
|
},
|
||||||
],
|
"baseUrl": "."
|
||||||
"compilerOptions": {
|
}
|
||||||
"paths": {
|
|
||||||
"~*": [
|
|
||||||
"./*"
|
|
||||||
],
|
|
||||||
"@/*": ["./*"]
|
|
||||||
},
|
|
||||||
"baseUrl": "."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,144 +1,137 @@
|
||||||
import { Storage } from "@plasmohq/storage"
|
import { Storage } from "@plasmohq/storage";
|
||||||
import type { WebHistory } from "./interfaces"
|
import type { WebHistory } from "./interfaces";
|
||||||
|
|
||||||
export const emptyArr: any[] = []
|
export const emptyArr: any[] = [];
|
||||||
|
|
||||||
export const initQueues = async (tabId: number) => {
|
export const initQueues = async (tabId: number) => {
|
||||||
const storage = new Storage({ area: "local" })
|
const storage = new Storage({ area: "local" });
|
||||||
|
|
||||||
let urlQueueListObj: any = await storage.get("urlQueueList")
|
const urlQueueListObj: any = await storage.get("urlQueueList");
|
||||||
let timeQueueListObj: any = await storage.get("timeQueueList")
|
const timeQueueListObj: any = await storage.get("timeQueueList");
|
||||||
|
|
||||||
if (!urlQueueListObj && !timeQueueListObj) {
|
if (!urlQueueListObj && !timeQueueListObj) {
|
||||||
await storage.set("urlQueueList", {
|
await storage.set("urlQueueList", {
|
||||||
urlQueueList: [{ tabsessionId: tabId, urlQueue: [] }]
|
urlQueueList: [{ tabsessionId: tabId, urlQueue: [] }],
|
||||||
})
|
});
|
||||||
await storage.set("timeQueueList", {
|
await storage.set("timeQueueList", {
|
||||||
timeQueueList: [{ tabsessionId: tabId, timeQueue: [] }]
|
timeQueueList: [{ tabsessionId: tabId, timeQueue: [] }],
|
||||||
})
|
});
|
||||||
|
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (urlQueueListObj.urlQueueList && timeQueueListObj.timeQueueList) {
|
if (urlQueueListObj.urlQueueList && timeQueueListObj.timeQueueList) {
|
||||||
const isUrlQueueThere = urlQueueListObj.urlQueueList.find(
|
const isUrlQueueThere = urlQueueListObj.urlQueueList.find(
|
||||||
(data: WebHistory) => data.tabsessionId === tabId
|
(data: WebHistory) => data.tabsessionId === tabId
|
||||||
)
|
);
|
||||||
const isTimeQueueThere = timeQueueListObj.timeQueueList.find(
|
const isTimeQueueThere = timeQueueListObj.timeQueueList.find(
|
||||||
(data: WebHistory) => data.tabsessionId === tabId
|
(data: WebHistory) => data.tabsessionId === tabId
|
||||||
)
|
);
|
||||||
|
|
||||||
if (!isUrlQueueThere) {
|
if (!isUrlQueueThere) {
|
||||||
urlQueueListObj.urlQueueList.push({ tabsessionId: tabId, urlQueue: [] })
|
urlQueueListObj.urlQueueList.push({ tabsessionId: tabId, urlQueue: [] });
|
||||||
|
|
||||||
await storage.set("urlQueueList", {
|
await storage.set("urlQueueList", {
|
||||||
urlQueueList: urlQueueListObj.urlQueueList
|
urlQueueList: urlQueueListObj.urlQueueList,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isTimeQueueThere) {
|
if (!isTimeQueueThere) {
|
||||||
timeQueueListObj.timeQueueList.push({
|
timeQueueListObj.timeQueueList.push({
|
||||||
tabsessionId: tabId,
|
tabsessionId: tabId,
|
||||||
timeQueue: []
|
timeQueue: [],
|
||||||
})
|
});
|
||||||
|
|
||||||
await storage.set("timeQueueList", {
|
await storage.set("timeQueueList", {
|
||||||
timeQueueList: timeQueueListObj.timeQueueList
|
timeQueueList: timeQueueListObj.timeQueueList,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export function getRenderedHtml() {
|
export function getRenderedHtml() {
|
||||||
return {
|
return {
|
||||||
url: window.location.href,
|
url: window.location.href,
|
||||||
entryTime: Date.now(),
|
entryTime: Date.now(),
|
||||||
title: document.title,
|
title: document.title,
|
||||||
renderedHtml: document.documentElement.outerHTML
|
renderedHtml: document.documentElement.outerHTML,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initWebHistory = async (tabId: number) => {
|
export const initWebHistory = async (tabId: number) => {
|
||||||
const storage = new Storage({ area: "local" })
|
const storage = new Storage({ area: "local" });
|
||||||
const result: any = await storage.get("webhistory")
|
const result: any = await storage.get("webhistory");
|
||||||
|
|
||||||
if (result === undefined) {
|
if (result === undefined) {
|
||||||
await storage.set("webhistory", { webhistory: emptyArr })
|
await storage.set("webhistory", { webhistory: emptyArr });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ifIdExists = result.webhistory.find(
|
const ifIdExists = result.webhistory.find((data: WebHistory) => data.tabsessionId === tabId);
|
||||||
(data: WebHistory) => data.tabsessionId === tabId
|
|
||||||
)
|
|
||||||
|
|
||||||
if (ifIdExists === undefined) {
|
if (ifIdExists === undefined) {
|
||||||
let webHistory = result.webhistory
|
const webHistory = result.webhistory;
|
||||||
const initData = {
|
const initData = {
|
||||||
tabsessionId: tabId,
|
tabsessionId: tabId,
|
||||||
tabHistory: emptyArr
|
tabHistory: emptyArr,
|
||||||
}
|
};
|
||||||
|
|
||||||
webHistory.push(initData)
|
webHistory.push(initData);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storage.set("webhistory", { webhistory: webHistory })
|
await storage.set("webhistory", { webhistory: webHistory });
|
||||||
return
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export function toIsoString(date: Date) {
|
export function toIsoString(date: Date) {
|
||||||
var tzo = -date.getTimezoneOffset(),
|
var tzo = -date.getTimezoneOffset(),
|
||||||
dif = tzo >= 0 ? "+" : "-",
|
dif = tzo >= 0 ? "+" : "-",
|
||||||
pad = function (num: number) {
|
pad = (num: number) => (num < 10 ? "0" : "") + num;
|
||||||
return (num < 10 ? "0" : "") + num
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
date.getFullYear() +
|
date.getFullYear() +
|
||||||
"-" +
|
"-" +
|
||||||
pad(date.getMonth() + 1) +
|
pad(date.getMonth() + 1) +
|
||||||
"-" +
|
"-" +
|
||||||
pad(date.getDate()) +
|
pad(date.getDate()) +
|
||||||
"T" +
|
"T" +
|
||||||
pad(date.getHours()) +
|
pad(date.getHours()) +
|
||||||
":" +
|
":" +
|
||||||
pad(date.getMinutes()) +
|
pad(date.getMinutes()) +
|
||||||
":" +
|
":" +
|
||||||
pad(date.getSeconds()) +
|
pad(date.getSeconds()) +
|
||||||
dif +
|
dif +
|
||||||
pad(Math.floor(Math.abs(tzo) / 60)) +
|
pad(Math.floor(Math.abs(tzo) / 60)) +
|
||||||
":" +
|
":" +
|
||||||
pad(Math.abs(tzo) % 60)
|
pad(Math.abs(tzo) % 60)
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const webhistoryToLangChainDocument = (
|
export const webhistoryToLangChainDocument = (tabId: number, tabHistory: any[]) => {
|
||||||
tabId: number,
|
const toSaveFinally = [];
|
||||||
tabHistory: any[]
|
for (let j = 0; j < tabHistory.length; j++) {
|
||||||
) => {
|
const mtadata = {
|
||||||
let toSaveFinally = []
|
BrowsingSessionId: `${tabId}`,
|
||||||
for (let j = 0; j < tabHistory.length; j++) {
|
VisitedWebPageURL: `${tabHistory[j].url}`,
|
||||||
const mtadata = {
|
VisitedWebPageTitle: `${tabHistory[j].title}`,
|
||||||
BrowsingSessionId: `${tabId}`,
|
VisitedWebPageDateWithTimeInISOString: `${toIsoString(new Date(tabHistory[j].entryTime))}`,
|
||||||
VisitedWebPageURL: `${tabHistory[j].url}`,
|
VisitedWebPageReffererURL: `${tabHistory[j].reffererUrl}`,
|
||||||
VisitedWebPageTitle: `${tabHistory[j].title}`,
|
VisitedWebPageVisitDurationInMilliseconds: tabHistory[j].duration,
|
||||||
VisitedWebPageDateWithTimeInISOString: `${toIsoString(new Date(tabHistory[j].entryTime))}`,
|
};
|
||||||
VisitedWebPageReffererURL: `${tabHistory[j].reffererUrl}`,
|
|
||||||
VisitedWebPageVisitDurationInMilliseconds: tabHistory[j].duration
|
|
||||||
}
|
|
||||||
|
|
||||||
toSaveFinally.push({
|
toSaveFinally.push({
|
||||||
metadata: mtadata,
|
metadata: mtadata,
|
||||||
pageContent: tabHistory[j].pageContentMarkdown
|
pageContent: tabHistory[j].pageContentMarkdown,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return toSaveFinally
|
return toSaveFinally;
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export interface WebHistory {
|
export interface WebHistory {
|
||||||
tabsessionId: number;
|
tabsessionId: number;
|
||||||
tabHistory: any[];
|
tabHistory: any[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
60
surfsense_web/.vscode/launch.json
vendored
60
surfsense_web/.vscode/launch.json
vendored
|
|
@ -1,31 +1,31 @@
|
||||||
{
|
{
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "Next.js: debug client-side",
|
"name": "Next.js: debug client-side",
|
||||||
"type": "chrome",
|
"type": "chrome",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"url": "http://localhost:3000",
|
"url": "http://localhost:3000",
|
||||||
"webRoot": "${workspaceFolder}"
|
"webRoot": "${workspaceFolder}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Next.js: debug server-side",
|
"name": "Next.js: debug server-side",
|
||||||
"type": "node-terminal",
|
"type": "node-terminal",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"command": "pnpm run debug:server",
|
"command": "pnpm run debug:server",
|
||||||
"skipFiles": ["<node_internals>/**"]
|
"skipFiles": ["<node_internals>/**"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Next.js: debug full stack",
|
"name": "Next.js: debug full stack",
|
||||||
"type": "node-terminal",
|
"type": "node-terminal",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"command": "pnpm run debug",
|
"command": "pnpm run debug",
|
||||||
"serverReadyAction": {
|
"serverReadyAction": {
|
||||||
"pattern": "- Local:.+(https?://.+)",
|
"pattern": "- Local:.+(https?://.+)",
|
||||||
"uriFormat": "%s",
|
"uriFormat": "%s",
|
||||||
"action": "debugWithChrome"
|
"action": "debugWithChrome"
|
||||||
},
|
},
|
||||||
"skipFiles": ["<node_internals>/**"]
|
"skipFiles": ["<node_internals>/**"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { source } from '@/lib/source';
|
import { createFromSource } from "fumadocs-core/search/server";
|
||||||
import { createFromSource } from 'fumadocs-core/search/server';
|
import { source } from "@/lib/source";
|
||||||
|
|
||||||
export const { GET } = createFromSource(source);
|
export const { GET } = createFromSource(source);
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,23 @@
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from "react";
|
||||||
import TokenHandler from '@/components/TokenHandler';
|
import TokenHandler from "@/components/TokenHandler";
|
||||||
|
|
||||||
export default function AuthCallbackPage() {
|
export default function AuthCallbackPage() {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-4">
|
<div className="container mx-auto p-4">
|
||||||
<h1 className="text-2xl font-bold mb-4">Authentication Callback</h1>
|
<h1 className="text-2xl font-bold mb-4">Authentication Callback</h1>
|
||||||
<Suspense fallback={<div className="flex items-center justify-center min-h-[200px]">
|
<Suspense
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
fallback={
|
||||||
</div>}>
|
<div className="flex items-center justify-center min-h-[200px]">
|
||||||
<TokenHandler
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||||
redirectPath="/dashboard"
|
</div>
|
||||||
tokenParamName="token"
|
}
|
||||||
storageKey="surfsense_bearer_token"
|
>
|
||||||
/>
|
<TokenHandler
|
||||||
</Suspense>
|
redirectPath="/dashboard"
|
||||||
</div>
|
tokenParamName="token"
|
||||||
);
|
storageKey="surfsense_bearer_token"
|
||||||
}
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,21 +1,25 @@
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from "react";
|
||||||
import ChatsPageClient from './chats-client';
|
import ChatsPageClient from "./chats-client";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: {
|
params: {
|
||||||
search_space_id: string;
|
search_space_id: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ChatsPage({ params }: PageProps) {
|
export default async function ChatsPage({ params }: PageProps) {
|
||||||
// Get search space ID from the route parameter
|
// Get search space ID from the route parameter
|
||||||
const { search_space_id: searchSpaceId } = await Promise.resolve(params);
|
const { search_space_id: searchSpaceId } = await Promise.resolve(params);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="flex items-center justify-center h-[60vh]">
|
<Suspense
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
fallback={
|
||||||
</div>}>
|
<div className="flex items-center justify-center h-[60vh]">
|
||||||
<ChatsPageClient searchSpaceId={searchSpaceId} />
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||||
</Suspense>
|
</div>
|
||||||
);
|
}
|
||||||
}
|
>
|
||||||
|
<ChatsPageClient searchSpaceId={searchSpaceId} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,40 @@
|
||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import {
|
import type React from "react";
|
||||||
SidebarInset,
|
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
|
||||||
SidebarProvider,
|
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
|
||||||
SidebarTrigger,
|
import { Separator } from "@/components/ui/separator";
|
||||||
} from "@/components/ui/sidebar"
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"
|
|
||||||
import React from 'react'
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider"
|
|
||||||
|
|
||||||
export function DashboardClientLayout({
|
export function DashboardClientLayout({
|
||||||
children,
|
children,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
navSecondary,
|
navSecondary,
|
||||||
navMain
|
navMain,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
navSecondary: any[];
|
navSecondary: any[];
|
||||||
navMain: any[];
|
navMain: any[];
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
{/* Use AppSidebarProvider which fetches user, search space, and recent chats */}
|
{/* Use AppSidebarProvider which fetches user, search space, and recent chats */}
|
||||||
<AppSidebarProvider
|
<AppSidebarProvider
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
navSecondary={navSecondary}
|
navSecondary={navSecondary}
|
||||||
navMain={navMain}
|
navMain={navMain}
|
||||||
/>
|
/>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2">
|
<header className="flex h-16 shrink-0 items-center gap-2">
|
||||||
<div className="flex items-center gap-2 px-4">
|
<div className="flex items-center gap-2 px-4">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator orientation="vertical" className="h-6" />
|
<Separator orientation="vertical" className="h-6" />
|
||||||
<ThemeTogglerComponent />
|
<ThemeTogglerComponent />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{children}
|
{children}
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { format } from "date-fns";
|
||||||
import { useRouter, useParams } from "next/navigation";
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import { Calendar as CalendarIcon, Edit, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import { getConnectorIcon } from "@/components/chat";
|
||||||
Edit,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
RefreshCw,
|
|
||||||
Calendar as CalendarIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -40,12 +18,9 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import {
|
import { Button } from "@/components/ui/button";
|
||||||
Tooltip,
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
TooltipContent,
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -53,14 +28,20 @@ import {
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { getConnectorIcon } from "@/components/chat";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { format } from "date-fns";
|
|
||||||
|
|
||||||
// Helper function to format date with time
|
// Helper function to format date with time
|
||||||
const formatDateTime = (dateString: string | null): string => {
|
const formatDateTime = (dateString: string | null): string => {
|
||||||
|
|
@ -83,14 +64,12 @@ export default function ConnectorsPage() {
|
||||||
|
|
||||||
const { connectors, isLoading, error, deleteConnector, indexConnector } =
|
const { connectors, isLoading, error, deleteConnector, indexConnector } =
|
||||||
useSearchSourceConnectors();
|
useSearchSourceConnectors();
|
||||||
const [connectorToDelete, setConnectorToDelete] = useState<number | null>(
|
const [connectorToDelete, setConnectorToDelete] = useState<number | null>(null);
|
||||||
null,
|
const [indexingConnectorId, setIndexingConnectorId] = useState<number | null>(null);
|
||||||
);
|
|
||||||
const [indexingConnectorId, setIndexingConnectorId] = useState<number | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [datePickerOpen, setDatePickerOpen] = useState(false);
|
const [datePickerOpen, setDatePickerOpen] = useState(false);
|
||||||
const [selectedConnectorForIndexing, setSelectedConnectorForIndexing] = useState<number | null>(null);
|
const [selectedConnectorForIndexing, setSelectedConnectorForIndexing] = useState<number | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
||||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
||||||
|
|
||||||
|
|
@ -127,21 +106,17 @@ export default function ConnectorsPage() {
|
||||||
if (selectedConnectorForIndexing === null) return;
|
if (selectedConnectorForIndexing === null) return;
|
||||||
|
|
||||||
setDatePickerOpen(false);
|
setDatePickerOpen(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIndexingConnectorId(selectedConnectorForIndexing);
|
setIndexingConnectorId(selectedConnectorForIndexing);
|
||||||
const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined;
|
const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined;
|
||||||
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
|
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
|
||||||
|
|
||||||
await indexConnector(selectedConnectorForIndexing, searchSpaceId, startDateStr, endDateStr);
|
await indexConnector(selectedConnectorForIndexing, searchSpaceId, startDateStr, endDateStr);
|
||||||
toast.success("Connector content indexing started");
|
toast.success("Connector content indexing started");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error indexing connector content:", error);
|
console.error("Error indexing connector content:", error);
|
||||||
toast.error(
|
toast.error(error instanceof Error ? error.message : "Failed to index connector content");
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to index connector content",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIndexingConnectorId(null);
|
setIndexingConnectorId(null);
|
||||||
setSelectedConnectorForIndexing(null);
|
setSelectedConnectorForIndexing(null);
|
||||||
|
|
@ -158,11 +133,7 @@ export default function ConnectorsPage() {
|
||||||
toast.success("Connector content indexing started");
|
toast.success("Connector content indexing started");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error indexing connector content:", error);
|
console.error("Error indexing connector content:", error);
|
||||||
toast.error(
|
toast.error(error instanceof Error ? error.message : "Failed to index connector content");
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to index connector content",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIndexingConnectorId(null);
|
setIndexingConnectorId(null);
|
||||||
}
|
}
|
||||||
|
|
@ -182,11 +153,7 @@ export default function ConnectorsPage() {
|
||||||
Manage your connected services and data sources.
|
Manage your connected services and data sources.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}>
|
||||||
onClick={() =>
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors/add`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Connector
|
Add Connector
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -195,9 +162,7 @@ export default function ConnectorsPage() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle>Your Connectors</CardTitle>
|
<CardTitle>Your Connectors</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>View and manage all your connected services.</CardDescription>
|
||||||
View and manage all your connected services.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|
@ -211,14 +176,9 @@ export default function ConnectorsPage() {
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<h3 className="text-lg font-medium mb-2">No connectors found</h3>
|
<h3 className="text-lg font-medium mb-2">No connectors found</h3>
|
||||||
<p className="text-muted-foreground mb-6">
|
<p className="text-muted-foreground mb-6">
|
||||||
You haven't added any connectors yet. Add one to enhance your
|
You haven't added any connectors yet. Add one to enhance your search capabilities.
|
||||||
search capabilities.
|
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}>
|
||||||
onClick={() =>
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors/add`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Your First Connector
|
Add Your First Connector
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -237,12 +197,8 @@ export default function ConnectorsPage() {
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{connectors.map((connector) => (
|
{connectors.map((connector) => (
|
||||||
<TableRow key={connector.id}>
|
<TableRow key={connector.id}>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">{connector.name}</TableCell>
|
||||||
{connector.name}
|
<TableCell>{getConnectorIcon(connector.connector_type)}</TableCell>
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{getConnectorIcon(connector.connector_type)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{connector.is_indexable
|
{connector.is_indexable
|
||||||
? formatDateTime(connector.last_indexed_at)
|
? formatDateTime(connector.last_indexed_at)
|
||||||
|
|
@ -258,21 +214,15 @@ export default function ConnectorsPage() {
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => handleOpenDatePicker(connector.id)}
|
||||||
handleOpenDatePicker(connector.id)
|
disabled={indexingConnectorId === connector.id}
|
||||||
}
|
|
||||||
disabled={
|
|
||||||
indexingConnectorId === connector.id
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{indexingConnectorId === connector.id ? (
|
{indexingConnectorId === connector.id ? (
|
||||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<CalendarIcon className="h-4 w-4" />
|
<CalendarIcon className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
<span className="sr-only">
|
<span className="sr-only">Index with Date Range</span>
|
||||||
Index with Date Range
|
|
||||||
</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
|
|
@ -286,21 +236,15 @@ export default function ConnectorsPage() {
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => handleQuickIndexConnector(connector.id)}
|
||||||
handleQuickIndexConnector(connector.id)
|
disabled={indexingConnectorId === connector.id}
|
||||||
}
|
|
||||||
disabled={
|
|
||||||
indexingConnectorId === connector.id
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{indexingConnectorId === connector.id ? (
|
{indexingConnectorId === connector.id ? (
|
||||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
<span className="sr-only">
|
<span className="sr-only">Quick Index</span>
|
||||||
Quick Index
|
|
||||||
</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
|
|
@ -315,7 +259,7 @@ export default function ConnectorsPage() {
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
router.push(
|
router.push(
|
||||||
`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`,
|
`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -328,9 +272,7 @@ export default function ConnectorsPage() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-destructive-foreground hover:bg-destructive/10"
|
className="text-destructive-foreground hover:bg-destructive/10"
|
||||||
onClick={() =>
|
onClick={() => setConnectorToDelete(connector.id)}
|
||||||
setConnectorToDelete(connector.id)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
<span className="sr-only">Delete</span>
|
<span className="sr-only">Delete</span>
|
||||||
|
|
@ -338,18 +280,14 @@ export default function ConnectorsPage() {
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>Delete Connector</AlertDialogTitle>
|
||||||
Delete Connector
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
Are you sure you want to delete this
|
Are you sure you want to delete this connector? This action cannot
|
||||||
connector? This action cannot be undone.
|
be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel
|
<AlertDialogCancel onClick={() => setConnectorToDelete(null)}>
|
||||||
onClick={() => setConnectorToDelete(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
|
|
@ -404,9 +342,7 @@ export default function ConnectorsPage() {
|
||||||
mode="single"
|
mode="single"
|
||||||
selected={startDate}
|
selected={startDate}
|
||||||
onSelect={setStartDate}
|
onSelect={setStartDate}
|
||||||
disabled={(date) =>
|
disabled={(date) => date > new Date() || (endDate ? date > endDate : false)}
|
||||||
date > new Date() || (endDate ? date > endDate : false)
|
|
||||||
}
|
|
||||||
initialFocus
|
initialFocus
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|
@ -493,9 +429,7 @@ export default function ConnectorsPage() {
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleIndexConnector}>
|
<Button onClick={handleIndexConnector}>Start Indexing</Button>
|
||||||
Start Indexing
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -1,280 +1,269 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import { useRouter, useParams } from "next/navigation";
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import { ArrowLeft, Check, Loader2 } from "lucide-react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ArrowLeft, Check, Loader2, Github } from "lucide-react";
|
import { getConnectorIcon } from "@/components/chat";
|
||||||
|
|
||||||
import { Form } from "@/components/ui/form";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
|
|
||||||
// Import Utils, Types, Hook, and Components
|
|
||||||
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
|
|
||||||
import { useConnectorEditPage } from "@/hooks/useConnectorEditPage";
|
|
||||||
import { EditConnectorLoadingSkeleton } from "@/components/editConnector/EditConnectorLoadingSkeleton";
|
import { EditConnectorLoadingSkeleton } from "@/components/editConnector/EditConnectorLoadingSkeleton";
|
||||||
import { EditConnectorNameForm } from "@/components/editConnector/EditConnectorNameForm";
|
import { EditConnectorNameForm } from "@/components/editConnector/EditConnectorNameForm";
|
||||||
import { EditGitHubConnectorConfig } from "@/components/editConnector/EditGitHubConnectorConfig";
|
import { EditGitHubConnectorConfig } from "@/components/editConnector/EditGitHubConnectorConfig";
|
||||||
import { EditSimpleTokenForm } from "@/components/editConnector/EditSimpleTokenForm";
|
import { EditSimpleTokenForm } from "@/components/editConnector/EditSimpleTokenForm";
|
||||||
import { getConnectorIcon } from "@/components/chat";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Form } from "@/components/ui/form";
|
||||||
|
import { useConnectorEditPage } from "@/hooks/useConnectorEditPage";
|
||||||
|
// Import Utils, Types, Hook, and Components
|
||||||
|
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
|
||||||
|
|
||||||
export default function EditConnectorPage() {
|
export default function EditConnectorPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchSpaceId = params.search_space_id as string;
|
const searchSpaceId = params.search_space_id as string;
|
||||||
// Ensure connectorId is parsed safely
|
// Ensure connectorId is parsed safely
|
||||||
const connectorIdParam = params.connector_id as string;
|
const connectorIdParam = params.connector_id as string;
|
||||||
const connectorId = connectorIdParam ? parseInt(connectorIdParam, 10) : NaN;
|
const connectorId = connectorIdParam ? parseInt(connectorIdParam, 10) : NaN;
|
||||||
|
|
||||||
// Use the custom hook to manage state and logic
|
// Use the custom hook to manage state and logic
|
||||||
const {
|
const {
|
||||||
connectorsLoading,
|
connectorsLoading,
|
||||||
connector,
|
connector,
|
||||||
isSaving,
|
isSaving,
|
||||||
editForm,
|
editForm,
|
||||||
patForm, // Needed for GitHub child component
|
patForm, // Needed for GitHub child component
|
||||||
handleSaveChanges,
|
handleSaveChanges,
|
||||||
// GitHub specific props for the child component
|
// GitHub specific props for the child component
|
||||||
editMode,
|
editMode,
|
||||||
setEditMode, // Pass down if needed by GitHub component
|
setEditMode, // Pass down if needed by GitHub component
|
||||||
originalPat,
|
originalPat,
|
||||||
currentSelectedRepos,
|
currentSelectedRepos,
|
||||||
fetchedRepos,
|
fetchedRepos,
|
||||||
setFetchedRepos,
|
setFetchedRepos,
|
||||||
newSelectedRepos,
|
newSelectedRepos,
|
||||||
setNewSelectedRepos,
|
setNewSelectedRepos,
|
||||||
isFetchingRepos,
|
isFetchingRepos,
|
||||||
handleFetchRepositories,
|
handleFetchRepositories,
|
||||||
handleRepoSelectionChange,
|
handleRepoSelectionChange,
|
||||||
} = useConnectorEditPage(connectorId, searchSpaceId);
|
} = useConnectorEditPage(connectorId, searchSpaceId);
|
||||||
|
|
||||||
// Redirect if connectorId is not a valid number after parsing
|
// Redirect if connectorId is not a valid number after parsing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNaN(connectorId)) {
|
if (Number.isNaN(connectorId)) {
|
||||||
toast.error("Invalid Connector ID.");
|
toast.error("Invalid Connector ID.");
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||||
}
|
}
|
||||||
}, [connectorId, router, searchSpaceId]);
|
}, [connectorId, router, searchSpaceId]);
|
||||||
|
|
||||||
// Loading State
|
// Loading State
|
||||||
if (connectorsLoading || !connector) {
|
if (connectorsLoading || !connector) {
|
||||||
// Handle NaN case before showing skeleton
|
// Handle NaN case before showing skeleton
|
||||||
if (isNaN(connectorId)) return null;
|
if (Number.isNaN(connectorId)) return null;
|
||||||
return <EditConnectorLoadingSkeleton />;
|
return <EditConnectorLoadingSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main Render using data/handlers from the hook
|
// Main Render using data/handlers from the hook
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 max-w-3xl">
|
<div className="container mx-auto py-8 max-w-3xl">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="mb-6"
|
className="mb-6"
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Connectors
|
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Connectors
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
>
|
>
|
||||||
<Card className="border-2 border-border">
|
<Card className="border-2 border-border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||||
{getConnectorIcon(connector.connector_type)}
|
{getConnectorIcon(connector.connector_type)}
|
||||||
Edit {getConnectorTypeDisplay(connector.connector_type)} Connector
|
Edit {getConnectorTypeDisplay(connector.connector_type)} Connector
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Modify connector name and configuration.</CardDescription>
|
||||||
Modify connector name and configuration.
|
</CardHeader>
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<Form {...editForm}>
|
<Form {...editForm}>
|
||||||
{/* Pass hook's handleSaveChanges */}
|
{/* Pass hook's handleSaveChanges */}
|
||||||
<form
|
<form onSubmit={editForm.handleSubmit(handleSaveChanges)} className="space-y-6">
|
||||||
onSubmit={editForm.handleSubmit(handleSaveChanges)}
|
<CardContent className="space-y-6">
|
||||||
className="space-y-6"
|
{/* Pass form control from hook */}
|
||||||
>
|
<EditConnectorNameForm control={editForm.control} />
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* Pass form control from hook */}
|
|
||||||
<EditConnectorNameForm control={editForm.control} />
|
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold">Configuration</h3>
|
<h3 className="text-lg font-semibold">Configuration</h3>
|
||||||
|
|
||||||
{/* == GitHub == */}
|
{/* == GitHub == */}
|
||||||
{connector.connector_type === "GITHUB_CONNECTOR" && (
|
{connector.connector_type === "GITHUB_CONNECTOR" && (
|
||||||
<EditGitHubConnectorConfig
|
<EditGitHubConnectorConfig
|
||||||
// Pass relevant state and handlers from hook
|
// Pass relevant state and handlers from hook
|
||||||
editMode={editMode}
|
editMode={editMode}
|
||||||
setEditMode={setEditMode} // Pass setter if child manages mode
|
setEditMode={setEditMode} // Pass setter if child manages mode
|
||||||
originalPat={originalPat}
|
originalPat={originalPat}
|
||||||
currentSelectedRepos={currentSelectedRepos}
|
currentSelectedRepos={currentSelectedRepos}
|
||||||
fetchedRepos={fetchedRepos}
|
fetchedRepos={fetchedRepos}
|
||||||
newSelectedRepos={newSelectedRepos}
|
newSelectedRepos={newSelectedRepos}
|
||||||
isFetchingRepos={isFetchingRepos}
|
isFetchingRepos={isFetchingRepos}
|
||||||
patForm={patForm}
|
patForm={patForm}
|
||||||
handleFetchRepositories={handleFetchRepositories}
|
handleFetchRepositories={handleFetchRepositories}
|
||||||
handleRepoSelectionChange={handleRepoSelectionChange}
|
handleRepoSelectionChange={handleRepoSelectionChange}
|
||||||
setNewSelectedRepos={setNewSelectedRepos}
|
setNewSelectedRepos={setNewSelectedRepos}
|
||||||
setFetchedRepos={setFetchedRepos}
|
setFetchedRepos={setFetchedRepos}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* == Slack == */}
|
{/* == Slack == */}
|
||||||
{connector.connector_type === "SLACK_CONNECTOR" && (
|
{connector.connector_type === "SLACK_CONNECTOR" && (
|
||||||
<EditSimpleTokenForm
|
<EditSimpleTokenForm
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
fieldName="SLACK_BOT_TOKEN"
|
fieldName="SLACK_BOT_TOKEN"
|
||||||
fieldLabel="Slack Bot Token"
|
fieldLabel="Slack Bot Token"
|
||||||
fieldDescription="Update the Slack Bot Token if needed."
|
fieldDescription="Update the Slack Bot Token if needed."
|
||||||
placeholder="Begins with xoxb-..."
|
placeholder="Begins with xoxb-..."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* == Notion == */}
|
{/* == Notion == */}
|
||||||
{connector.connector_type === "NOTION_CONNECTOR" && (
|
{connector.connector_type === "NOTION_CONNECTOR" && (
|
||||||
<EditSimpleTokenForm
|
<EditSimpleTokenForm
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
fieldName="NOTION_INTEGRATION_TOKEN"
|
fieldName="NOTION_INTEGRATION_TOKEN"
|
||||||
fieldLabel="Notion Integration Token"
|
fieldLabel="Notion Integration Token"
|
||||||
fieldDescription="Update the Notion Integration Token if needed."
|
fieldDescription="Update the Notion Integration Token if needed."
|
||||||
placeholder="Begins with secret_..."
|
placeholder="Begins with secret_..."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* == Serper == */}
|
{/* == Serper == */}
|
||||||
{connector.connector_type === "SERPER_API" && (
|
{connector.connector_type === "SERPER_API" && (
|
||||||
<EditSimpleTokenForm
|
<EditSimpleTokenForm
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
fieldName="SERPER_API_KEY"
|
fieldName="SERPER_API_KEY"
|
||||||
fieldLabel="Serper API Key"
|
fieldLabel="Serper API Key"
|
||||||
fieldDescription="Update the Serper API Key if needed."
|
fieldDescription="Update the Serper API Key if needed."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* == Tavily == */}
|
{/* == Tavily == */}
|
||||||
{connector.connector_type === "TAVILY_API" && (
|
{connector.connector_type === "TAVILY_API" && (
|
||||||
<EditSimpleTokenForm
|
<EditSimpleTokenForm
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
fieldName="TAVILY_API_KEY"
|
fieldName="TAVILY_API_KEY"
|
||||||
fieldLabel="Tavily API Key"
|
fieldLabel="Tavily API Key"
|
||||||
fieldDescription="Update the Tavily API Key if needed."
|
fieldDescription="Update the Tavily API Key if needed."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* == Linear == */}
|
{/* == Linear == */}
|
||||||
{connector.connector_type === "LINEAR_CONNECTOR" && (
|
{connector.connector_type === "LINEAR_CONNECTOR" && (
|
||||||
<EditSimpleTokenForm
|
<EditSimpleTokenForm
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
fieldName="LINEAR_API_KEY"
|
fieldName="LINEAR_API_KEY"
|
||||||
fieldLabel="Linear API Key"
|
fieldLabel="Linear API Key"
|
||||||
fieldDescription="Update your Linear API Key if needed."
|
fieldDescription="Update your Linear API Key if needed."
|
||||||
placeholder="Begins with lin_api_..."
|
placeholder="Begins with lin_api_..."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* == Jira == */}
|
{/* == Jira == */}
|
||||||
{connector.connector_type === "JIRA_CONNECTOR" && (
|
{connector.connector_type === "JIRA_CONNECTOR" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<EditSimpleTokenForm
|
<EditSimpleTokenForm
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
fieldName="JIRA_BASE_URL"
|
fieldName="JIRA_BASE_URL"
|
||||||
fieldLabel="Jira Base URL"
|
fieldLabel="Jira Base URL"
|
||||||
fieldDescription="Update your Jira instance URL if needed."
|
fieldDescription="Update your Jira instance URL if needed."
|
||||||
placeholder="https://yourcompany.atlassian.net"
|
placeholder="https://yourcompany.atlassian.net"
|
||||||
/>
|
/>
|
||||||
<EditSimpleTokenForm
|
<EditSimpleTokenForm
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
fieldName="JIRA_EMAIL"
|
fieldName="JIRA_EMAIL"
|
||||||
fieldLabel="Jira Email"
|
fieldLabel="Jira Email"
|
||||||
fieldDescription="Update your Atlassian account email if needed."
|
fieldDescription="Update your Atlassian account email if needed."
|
||||||
placeholder="your.email@company.com"
|
placeholder="your.email@company.com"
|
||||||
/>
|
/>
|
||||||
<EditSimpleTokenForm
|
<EditSimpleTokenForm
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
fieldName="JIRA_API_TOKEN"
|
fieldName="JIRA_API_TOKEN"
|
||||||
fieldLabel="Jira API Token"
|
fieldLabel="Jira API Token"
|
||||||
fieldDescription="Update your Jira API Token if needed."
|
fieldDescription="Update your Jira API Token if needed."
|
||||||
placeholder="Your Jira API Token"
|
placeholder="Your Jira API Token"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* == Confluence == */}
|
{/* == Confluence == */}
|
||||||
{connector.connector_type === "CONFLUENCE_CONNECTOR" && (
|
{connector.connector_type === "CONFLUENCE_CONNECTOR" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<EditSimpleTokenForm
|
<EditSimpleTokenForm
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
fieldName="CONFLUENCE_BASE_URL"
|
fieldName="CONFLUENCE_BASE_URL"
|
||||||
fieldLabel="Confluence Base URL"
|
fieldLabel="Confluence Base URL"
|
||||||
fieldDescription="Update your Confluence instance URL if needed."
|
fieldDescription="Update your Confluence instance URL if needed."
|
||||||
placeholder="https://yourcompany.atlassian.net"
|
placeholder="https://yourcompany.atlassian.net"
|
||||||
/>
|
/>
|
||||||
<EditSimpleTokenForm
|
<EditSimpleTokenForm
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
fieldName="CONFLUENCE_EMAIL"
|
fieldName="CONFLUENCE_EMAIL"
|
||||||
fieldLabel="Confluence Email"
|
fieldLabel="Confluence Email"
|
||||||
fieldDescription="Update your Atlassian account email if needed."
|
fieldDescription="Update your Atlassian account email if needed."
|
||||||
placeholder="your.email@company.com"
|
placeholder="your.email@company.com"
|
||||||
/>
|
/>
|
||||||
<EditSimpleTokenForm
|
<EditSimpleTokenForm
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
fieldName="CONFLUENCE_API_TOKEN"
|
fieldName="CONFLUENCE_API_TOKEN"
|
||||||
fieldLabel="Confluence API Token"
|
fieldLabel="Confluence API Token"
|
||||||
fieldDescription="Update your Confluence API Token if needed."
|
fieldDescription="Update your Confluence API Token if needed."
|
||||||
placeholder="Your Confluence API Token"
|
placeholder="Your Confluence API Token"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* == Linkup == */}
|
{/* == Linkup == */}
|
||||||
{connector.connector_type === "LINKUP_API" && (
|
{connector.connector_type === "LINKUP_API" && (
|
||||||
<EditSimpleTokenForm
|
<EditSimpleTokenForm
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
fieldName="LINKUP_API_KEY"
|
fieldName="LINKUP_API_KEY"
|
||||||
fieldLabel="Linkup API Key"
|
fieldLabel="Linkup API Key"
|
||||||
fieldDescription="Update your Linkup API Key if needed."
|
fieldDescription="Update your Linkup API Key if needed."
|
||||||
placeholder="Begins with linkup_..."
|
placeholder="Begins with linkup_..."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* == Discord == */}
|
{/* == Discord == */}
|
||||||
{connector.connector_type === "DISCORD_CONNECTOR" && (
|
{connector.connector_type === "DISCORD_CONNECTOR" && (
|
||||||
<EditSimpleTokenForm
|
<EditSimpleTokenForm
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
fieldName="DISCORD_BOT_TOKEN"
|
fieldName="DISCORD_BOT_TOKEN"
|
||||||
fieldLabel="Discord Bot Token"
|
fieldLabel="Discord Bot Token"
|
||||||
fieldDescription="Update the Discord Bot Token if needed."
|
fieldDescription="Update the Discord Bot Token if needed."
|
||||||
placeholder="Bot token..."
|
placeholder="Bot token..."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="border-t pt-6">
|
<CardFooter className="border-t pt-6">
|
||||||
<Button
|
<Button type="submit" disabled={isSaving} className="w-full sm:w-auto">
|
||||||
type="submit"
|
{isSaving ? (
|
||||||
disabled={isSaving}
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
className="w-full sm:w-auto"
|
) : (
|
||||||
>
|
<Check className="mr-2 h-4 w-4" />
|
||||||
{isSaving ? (
|
)}
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
Save Changes
|
||||||
) : (
|
</Button>
|
||||||
<Check className="mr-2 h-4 w-4" />
|
</CardFooter>
|
||||||
)}
|
</form>
|
||||||
Save Changes
|
</Form>
|
||||||
</Button>
|
</Card>
|
||||||
</CardFooter>
|
</motion.div>
|
||||||
</form>
|
</div>
|
||||||
</Form>
|
);
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,311 +1,286 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useRouter, useParams } from "next/navigation";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { motion } from "framer-motion";
|
||||||
import * as z from "zod";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
useSearchSourceConnectors,
|
Form,
|
||||||
SearchSourceConnector,
|
FormControl,
|
||||||
} from "@/hooks/useSearchSourceConnectors";
|
FormDescription,
|
||||||
import {
|
FormField,
|
||||||
Form,
|
FormItem,
|
||||||
FormControl,
|
FormLabel,
|
||||||
FormDescription,
|
FormMessage,
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
type SearchSourceConnector,
|
||||||
CardContent,
|
useSearchSourceConnectors,
|
||||||
CardDescription,
|
} from "@/hooks/useSearchSourceConnectors";
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
|
|
||||||
// Define the form schema with Zod
|
// Define the form schema with Zod
|
||||||
const apiConnectorFormSchema = z.object({
|
const apiConnectorFormSchema = z.object({
|
||||||
name: z.string().min(3, {
|
name: z.string().min(3, {
|
||||||
message: "Connector name must be at least 3 characters.",
|
message: "Connector name must be at least 3 characters.",
|
||||||
}),
|
}),
|
||||||
api_key: z.string().min(10, {
|
api_key: z.string().min(10, {
|
||||||
message: "API key is required and must be valid.",
|
message: "API key is required and must be valid.",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper function to get connector type display name
|
// Helper function to get connector type display name
|
||||||
const getConnectorTypeDisplay = (type: string): string => {
|
const getConnectorTypeDisplay = (type: string): string => {
|
||||||
const typeMap: Record<string, string> = {
|
const typeMap: Record<string, string> = {
|
||||||
SERPER_API: "Serper API",
|
SERPER_API: "Serper API",
|
||||||
TAVILY_API: "Tavily API",
|
TAVILY_API: "Tavily API",
|
||||||
SLACK_CONNECTOR: "Slack Connector",
|
SLACK_CONNECTOR: "Slack Connector",
|
||||||
NOTION_CONNECTOR: "Notion Connector",
|
NOTION_CONNECTOR: "Notion Connector",
|
||||||
GITHUB_CONNECTOR: "GitHub Connector",
|
GITHUB_CONNECTOR: "GitHub Connector",
|
||||||
LINEAR_CONNECTOR: "Linear Connector",
|
LINEAR_CONNECTOR: "Linear Connector",
|
||||||
JIRA_CONNECTOR: "Jira Connector",
|
JIRA_CONNECTOR: "Jira Connector",
|
||||||
DISCORD_CONNECTOR: "Discord Connector",
|
DISCORD_CONNECTOR: "Discord Connector",
|
||||||
LINKUP_API: "Linkup",
|
LINKUP_API: "Linkup",
|
||||||
// Add other connector types here as needed
|
// Add other connector types here as needed
|
||||||
};
|
};
|
||||||
return typeMap[type] || type;
|
return typeMap[type] || type;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Define the type for the form values
|
// Define the type for the form values
|
||||||
type ApiConnectorFormValues = z.infer<typeof apiConnectorFormSchema>;
|
type ApiConnectorFormValues = z.infer<typeof apiConnectorFormSchema>;
|
||||||
|
|
||||||
|
// Get API key field name based on connector type
|
||||||
|
const getApiKeyFieldName = (connectorType: string): string => {
|
||||||
|
const fieldMap: Record<string, string> = {
|
||||||
|
SERPER_API: "SERPER_API_KEY",
|
||||||
|
TAVILY_API: "TAVILY_API_KEY",
|
||||||
|
SLACK_CONNECTOR: "SLACK_BOT_TOKEN",
|
||||||
|
NOTION_CONNECTOR: "NOTION_INTEGRATION_TOKEN",
|
||||||
|
GITHUB_CONNECTOR: "GITHUB_PAT",
|
||||||
|
DISCORD_CONNECTOR: "DISCORD_BOT_TOKEN",
|
||||||
|
LINKUP_API: "LINKUP_API_KEY",
|
||||||
|
};
|
||||||
|
return fieldMap[connectorType] || "";
|
||||||
|
};
|
||||||
|
|
||||||
export default function EditConnectorPage() {
|
export default function EditConnectorPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchSpaceId = params.search_space_id as string;
|
const searchSpaceId = params.search_space_id as string;
|
||||||
const connectorId = parseInt(params.connector_id as string, 10);
|
const connectorId = parseInt(params.connector_id as string, 10);
|
||||||
|
|
||||||
const { connectors, updateConnector } = useSearchSourceConnectors();
|
const { connectors, updateConnector } = useSearchSourceConnectors();
|
||||||
const [connector, setConnector] = useState<SearchSourceConnector | null>(
|
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
|
||||||
null,
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
// console.log("connector", connector);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
// Initialize the form
|
||||||
// console.log("connector", connector);
|
const form = useForm<ApiConnectorFormValues>({
|
||||||
// Initialize the form
|
resolver: zodResolver(apiConnectorFormSchema),
|
||||||
const form = useForm<ApiConnectorFormValues>({
|
defaultValues: {
|
||||||
resolver: zodResolver(apiConnectorFormSchema),
|
name: "",
|
||||||
defaultValues: {
|
api_key: "",
|
||||||
name: "",
|
},
|
||||||
api_key: "",
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get API key field name based on connector type
|
// Find connector in the list
|
||||||
const getApiKeyFieldName = (connectorType: string): string => {
|
useEffect(() => {
|
||||||
const fieldMap: Record<string, string> = {
|
const currentConnector = connectors.find((c) => c.id === connectorId);
|
||||||
SERPER_API: "SERPER_API_KEY",
|
|
||||||
TAVILY_API: "TAVILY_API_KEY",
|
|
||||||
SLACK_CONNECTOR: "SLACK_BOT_TOKEN",
|
|
||||||
NOTION_CONNECTOR: "NOTION_INTEGRATION_TOKEN",
|
|
||||||
GITHUB_CONNECTOR: "GITHUB_PAT",
|
|
||||||
DISCORD_CONNECTOR: "DISCORD_BOT_TOKEN",
|
|
||||||
LINKUP_API: "LINKUP_API_KEY",
|
|
||||||
};
|
|
||||||
return fieldMap[connectorType] || "";
|
|
||||||
};
|
|
||||||
|
|
||||||
// Find connector in the list
|
if (currentConnector) {
|
||||||
useEffect(() => {
|
setConnector(currentConnector);
|
||||||
const currentConnector = connectors.find((c) => c.id === connectorId);
|
|
||||||
|
|
||||||
if (currentConnector) {
|
// Check if connector type is supported
|
||||||
setConnector(currentConnector);
|
const apiKeyField = getApiKeyFieldName(currentConnector.connector_type);
|
||||||
|
if (apiKeyField) {
|
||||||
|
form.reset({
|
||||||
|
name: currentConnector.name,
|
||||||
|
api_key: currentConnector.config[apiKeyField] || "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Redirect if not a supported connector type
|
||||||
|
toast.error("This connector type is not supported for editing");
|
||||||
|
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if connector type is supported
|
setIsLoading(false);
|
||||||
const apiKeyField = getApiKeyFieldName(currentConnector.connector_type);
|
} else if (!isLoading && connectors.length > 0) {
|
||||||
if (apiKeyField) {
|
// If connectors are loaded but this one isn't found
|
||||||
form.reset({
|
toast.error("Connector not found");
|
||||||
name: currentConnector.name,
|
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||||
api_key: currentConnector.config[apiKeyField] || "",
|
}
|
||||||
});
|
}, [connectors, connectorId, form, router, searchSpaceId, isLoading]);
|
||||||
} else {
|
|
||||||
// Redirect if not a supported connector type
|
|
||||||
toast.error("This connector type is not supported for editing");
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(false);
|
// Handle form submission
|
||||||
} else if (!isLoading && connectors.length > 0) {
|
const onSubmit = async (values: ApiConnectorFormValues) => {
|
||||||
// If connectors are loaded but this one isn't found
|
if (!connector) return;
|
||||||
toast.error("Connector not found");
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
|
||||||
}
|
|
||||||
}, [connectors, connectorId, form, router, searchSpaceId, isLoading]);
|
|
||||||
|
|
||||||
// Handle form submission
|
setIsSubmitting(true);
|
||||||
const onSubmit = async (values: ApiConnectorFormValues) => {
|
try {
|
||||||
if (!connector) return;
|
const apiKeyField = getApiKeyFieldName(connector.connector_type);
|
||||||
|
|
||||||
setIsSubmitting(true);
|
// Only update the API key if a new one was provided
|
||||||
try {
|
const updatedConfig = { ...connector.config };
|
||||||
const apiKeyField = getApiKeyFieldName(connector.connector_type);
|
if (values.api_key) {
|
||||||
|
updatedConfig[apiKeyField] = values.api_key;
|
||||||
|
}
|
||||||
|
|
||||||
// Only update the API key if a new one was provided
|
await updateConnector(connectorId, {
|
||||||
const updatedConfig = { ...connector.config };
|
name: values.name,
|
||||||
if (values.api_key) {
|
connector_type: connector.connector_type,
|
||||||
updatedConfig[apiKeyField] = values.api_key;
|
config: updatedConfig,
|
||||||
}
|
is_indexable: connector.is_indexable,
|
||||||
|
last_indexed_at: connector.last_indexed_at,
|
||||||
|
});
|
||||||
|
|
||||||
await updateConnector(connectorId, {
|
toast.success("Connector updated successfully!");
|
||||||
name: values.name,
|
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||||
connector_type: connector.connector_type,
|
} catch (error) {
|
||||||
config: updatedConfig,
|
console.error("Error updating connector:", error);
|
||||||
is_indexable: connector.is_indexable,
|
toast.error(error instanceof Error ? error.message : "Failed to update connector");
|
||||||
last_indexed_at: connector.last_indexed_at,
|
} finally {
|
||||||
});
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
toast.success("Connector updated successfully!");
|
if (isLoading) {
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
return (
|
||||||
} catch (error) {
|
<div className="container mx-auto py-8 max-w-3xl flex justify-center items-center min-h-[60vh]">
|
||||||
console.error("Error updating connector:", error);
|
<div className="animate-pulse text-center">
|
||||||
toast.error(
|
<div className="h-8 w-48 bg-muted rounded mx-auto mb-4"></div>
|
||||||
error instanceof Error ? error.message : "Failed to update connector",
|
<div className="h-4 w-64 bg-muted rounded mx-auto"></div>
|
||||||
);
|
</div>
|
||||||
} finally {
|
</div>
|
||||||
setIsSubmitting(false);
|
);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
return (
|
||||||
return (
|
<div className="container mx-auto py-8 max-w-3xl">
|
||||||
<div className="container mx-auto py-8 max-w-3xl flex justify-center items-center min-h-[60vh]">
|
<Button
|
||||||
<div className="animate-pulse text-center">
|
variant="ghost"
|
||||||
<div className="h-8 w-48 bg-muted rounded mx-auto mb-4"></div>
|
className="mb-6"
|
||||||
<div className="h-4 w-64 bg-muted rounded mx-auto"></div>
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
|
||||||
</div>
|
>
|
||||||
</div>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
);
|
Back to Connectors
|
||||||
}
|
</Button>
|
||||||
|
|
||||||
return (
|
<motion.div
|
||||||
<div className="container mx-auto py-8 max-w-3xl">
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<Button
|
animate={{ opacity: 1, y: 0 }}
|
||||||
variant="ghost"
|
transition={{ duration: 0.5 }}
|
||||||
className="mb-6"
|
>
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
|
<Card className="border-2 border-border">
|
||||||
>
|
<CardHeader>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<CardTitle className="text-2xl font-bold">
|
||||||
Back to Connectors
|
Edit {connector ? getConnectorTypeDisplay(connector.connector_type) : ""} Connector
|
||||||
</Button>
|
</CardTitle>
|
||||||
|
<CardDescription>Update your connector settings.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Alert className="mb-6 bg-muted">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>API Key Security</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Your API key is stored securely. For security reasons, we don't display your
|
||||||
|
existing API key. If you don't update the API key field, your existing key will be
|
||||||
|
preserved.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<motion.div
|
<Form {...form}>
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<FormField
|
||||||
transition={{ duration: 0.5 }}
|
control={form.control}
|
||||||
>
|
name="name"
|
||||||
<Card className="border-2 border-border">
|
render={({ field }) => (
|
||||||
<CardHeader>
|
<FormItem>
|
||||||
<CardTitle className="text-2xl font-bold">
|
<FormLabel>Connector Name</FormLabel>
|
||||||
Edit{" "}
|
<FormControl>
|
||||||
{connector
|
<Input placeholder="My API Connector" {...field} />
|
||||||
? getConnectorTypeDisplay(connector.connector_type)
|
</FormControl>
|
||||||
: ""}{" "}
|
<FormDescription>A friendly name to identify this connector.</FormDescription>
|
||||||
Connector
|
<FormMessage />
|
||||||
</CardTitle>
|
</FormItem>
|
||||||
<CardDescription>Update your connector settings.</CardDescription>
|
)}
|
||||||
</CardHeader>
|
/>
|
||||||
<CardContent>
|
|
||||||
<Alert className="mb-6 bg-muted">
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<AlertTitle>API Key Security</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Your API key is stored securely. For security reasons, we don't
|
|
||||||
display your existing API key. If you don't update the API key
|
|
||||||
field, your existing key will be preserved.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Form {...form}>
|
<FormField
|
||||||
<form
|
control={form.control}
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
name="api_key"
|
||||||
className="space-y-6"
|
render={({ field }) => (
|
||||||
>
|
<FormItem>
|
||||||
<FormField
|
<FormLabel>
|
||||||
control={form.control}
|
{connector?.connector_type === "SLACK_CONNECTOR"
|
||||||
name="name"
|
? "Slack Bot Token"
|
||||||
render={({ field }) => (
|
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||||
<FormItem>
|
? "Notion Integration Token"
|
||||||
<FormLabel>Connector Name</FormLabel>
|
: connector?.connector_type === "GITHUB_CONNECTOR"
|
||||||
<FormControl>
|
? "GitHub Personal Access Token (PAT)"
|
||||||
<Input placeholder="My API Connector" {...field} />
|
: connector?.connector_type === "LINKUP_API"
|
||||||
</FormControl>
|
? "Linkup API Key"
|
||||||
<FormDescription>
|
: "API Key"}
|
||||||
A friendly name to identify this connector.
|
</FormLabel>
|
||||||
</FormDescription>
|
<FormControl>
|
||||||
<FormMessage />
|
<Input
|
||||||
</FormItem>
|
type="password"
|
||||||
)}
|
placeholder={
|
||||||
/>
|
connector?.connector_type === "SLACK_CONNECTOR"
|
||||||
|
? "Enter new Slack Bot Token (optional)"
|
||||||
|
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||||
|
? "Enter new Notion Token (optional)"
|
||||||
|
: connector?.connector_type === "GITHUB_CONNECTOR"
|
||||||
|
? "Enter new GitHub PAT (optional)"
|
||||||
|
: connector?.connector_type === "LINKUP_API"
|
||||||
|
? "Enter new Linkup API Key (optional)"
|
||||||
|
: "Enter new API key (optional)"
|
||||||
|
}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{connector?.connector_type === "SLACK_CONNECTOR"
|
||||||
|
? "Enter a new Slack Bot Token or leave blank to keep your existing token."
|
||||||
|
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||||
|
? "Enter a new Notion Integration Token or leave blank to keep your existing token."
|
||||||
|
: connector?.connector_type === "GITHUB_CONNECTOR"
|
||||||
|
? "Enter a new GitHub PAT or leave blank to keep your existing token."
|
||||||
|
: connector?.connector_type === "LINKUP_API"
|
||||||
|
? "Enter a new Linkup API Key or leave blank to keep your existing key."
|
||||||
|
: "Enter a new API key or leave blank to keep your existing key."}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<div className="flex justify-end">
|
||||||
control={form.control}
|
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||||
name="api_key"
|
{isSubmitting ? (
|
||||||
render={({ field }) => (
|
<>
|
||||||
<FormItem>
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<FormLabel>
|
Updating...
|
||||||
{connector?.connector_type === "SLACK_CONNECTOR"
|
</>
|
||||||
? "Slack Bot Token"
|
) : (
|
||||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
<>
|
||||||
? "Notion Integration Token"
|
<Check className="mr-2 h-4 w-4" />
|
||||||
: connector?.connector_type === "GITHUB_CONNECTOR"
|
Update Connector
|
||||||
? "GitHub Personal Access Token (PAT)"
|
</>
|
||||||
: connector?.connector_type === "LINKUP_API"
|
)}
|
||||||
? "Linkup API Key"
|
</Button>
|
||||||
: "API Key"}
|
</div>
|
||||||
</FormLabel>
|
</form>
|
||||||
<FormControl>
|
</Form>
|
||||||
<Input
|
</CardContent>
|
||||||
type="password"
|
</Card>
|
||||||
placeholder={
|
</motion.div>
|
||||||
connector?.connector_type === "SLACK_CONNECTOR"
|
</div>
|
||||||
? "Enter new Slack Bot Token (optional)"
|
);
|
||||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
|
||||||
? "Enter new Notion Token (optional)"
|
|
||||||
: connector?.connector_type ===
|
|
||||||
"GITHUB_CONNECTOR"
|
|
||||||
? "Enter new GitHub PAT (optional)"
|
|
||||||
: connector?.connector_type === "LINKUP_API"
|
|
||||||
? "Enter new Linkup API Key (optional)"
|
|
||||||
: "Enter new API key (optional)"
|
|
||||||
}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{connector?.connector_type === "SLACK_CONNECTOR"
|
|
||||||
? "Enter a new Slack Bot Token or leave blank to keep your existing token."
|
|
||||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
|
||||||
? "Enter a new Notion Integration Token or leave blank to keep your existing token."
|
|
||||||
: connector?.connector_type === "GITHUB_CONNECTOR"
|
|
||||||
? "Enter a new GitHub PAT or leave blank to keep your existing token."
|
|
||||||
: connector?.connector_type === "LINKUP_API"
|
|
||||||
? "Enter a new Linkup API Key or leave blank to keep your existing key."
|
|
||||||
: "Enter a new API key or leave blank to keep your existing key."}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Updating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Check className="mr-2 h-4 w-4" />
|
|
||||||
Update Connector
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,324 +1,297 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter, useParams } from "next/navigation";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { motion } from "framer-motion";
|
||||||
import * as z from "zod";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import * as z from "zod";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
|
|
||||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||||
|
|
||||||
// Define the form schema with Zod
|
// Define the form schema with Zod
|
||||||
const confluenceConnectorFormSchema = z.object({
|
const confluenceConnectorFormSchema = z.object({
|
||||||
name: z.string().min(3, {
|
name: z.string().min(3, {
|
||||||
message: "Connector name must be at least 3 characters.",
|
message: "Connector name must be at least 3 characters.",
|
||||||
}),
|
}),
|
||||||
base_url: z
|
base_url: z
|
||||||
.string()
|
.string()
|
||||||
.url({
|
.url({
|
||||||
message:
|
message: "Please enter a valid Confluence URL (e.g., https://yourcompany.atlassian.net)",
|
||||||
"Please enter a valid Confluence URL (e.g., https://yourcompany.atlassian.net)",
|
})
|
||||||
})
|
.refine(
|
||||||
.refine(
|
(url) => {
|
||||||
(url) => {
|
return url.includes("atlassian.net") || url.includes("confluence");
|
||||||
return url.includes("atlassian.net") || url.includes("confluence");
|
},
|
||||||
},
|
{
|
||||||
{
|
message: "Please enter a valid Confluence instance URL",
|
||||||
message: "Please enter a valid Confluence instance URL",
|
}
|
||||||
},
|
),
|
||||||
),
|
email: z.string().email({
|
||||||
email: z.string().email({
|
message: "Please enter a valid email address.",
|
||||||
message: "Please enter a valid email address.",
|
}),
|
||||||
}),
|
api_token: z.string().min(10, {
|
||||||
api_token: z.string().min(10, {
|
message: "Confluence API Token is required and must be valid.",
|
||||||
message: "Confluence API Token is required and must be valid.",
|
}),
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Define the type for the form values
|
// Define the type for the form values
|
||||||
type ConfluenceConnectorFormValues = z.infer<typeof confluenceConnectorFormSchema>;
|
type ConfluenceConnectorFormValues = z.infer<typeof confluenceConnectorFormSchema>;
|
||||||
|
|
||||||
export default function ConfluenceConnectorPage() {
|
export default function ConfluenceConnectorPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchSpaceId = params.search_space_id as string;
|
const searchSpaceId = params.search_space_id as string;
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { createConnector } = useSearchSourceConnectors();
|
const { createConnector } = useSearchSourceConnectors();
|
||||||
|
|
||||||
// Initialize the form
|
// Initialize the form
|
||||||
const form = useForm<ConfluenceConnectorFormValues>({
|
const form = useForm<ConfluenceConnectorFormValues>({
|
||||||
resolver: zodResolver(confluenceConnectorFormSchema),
|
resolver: zodResolver(confluenceConnectorFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "Confluence Connector",
|
name: "Confluence Connector",
|
||||||
base_url: "",
|
base_url: "",
|
||||||
email: "",
|
email: "",
|
||||||
api_token: "",
|
api_token: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
const onSubmit = async (values: ConfluenceConnectorFormValues) => {
|
const onSubmit = async (values: ConfluenceConnectorFormValues) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await createConnector({
|
await createConnector({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
connector_type: "CONFLUENCE_CONNECTOR",
|
connector_type: "CONFLUENCE_CONNECTOR",
|
||||||
config: {
|
config: {
|
||||||
CONFLUENCE_BASE_URL: values.base_url,
|
CONFLUENCE_BASE_URL: values.base_url,
|
||||||
CONFLUENCE_EMAIL: values.email,
|
CONFLUENCE_EMAIL: values.email,
|
||||||
CONFLUENCE_API_TOKEN: values.api_token,
|
CONFLUENCE_API_TOKEN: values.api_token,
|
||||||
},
|
},
|
||||||
is_indexable: true,
|
is_indexable: true,
|
||||||
last_indexed_at: null,
|
last_indexed_at: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Confluence connector created successfully!");
|
toast.success("Confluence connector created successfully!");
|
||||||
|
|
||||||
// Navigate back to connectors page
|
// Navigate back to connectors page
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating connector:", error);
|
console.error("Error creating connector:", error);
|
||||||
toast.error(
|
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||||
error instanceof Error ? error.message : "Failed to create connector",
|
} finally {
|
||||||
);
|
setIsSubmitting(false);
|
||||||
} finally {
|
}
|
||||||
setIsSubmitting(false);
|
};
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 max-w-3xl">
|
<div className="container mx-auto py-8 max-w-3xl">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="mb-6"
|
className="mb-6"
|
||||||
onClick={() =>
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors/add`)
|
>
|
||||||
}
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
>
|
Back to Connectors
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
</Button>
|
||||||
Back to Connectors
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
>
|
>
|
||||||
<Tabs defaultValue="connect" className="w-full">
|
<Tabs defaultValue="connect" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="connect">
|
<TabsContent value="connect">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Connect to Confluence</CardTitle>
|
<CardTitle>Connect to Confluence</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Connect your Confluence instance to index pages and comments from your spaces.
|
Connect your Confluence instance to index pages and comments from your spaces.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<Alert>
|
<Alert>
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
You'll need to create an API token from your{" "}
|
You'll need to create an API token from your{" "}
|
||||||
<a
|
<a
|
||||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-medium underline underline-offset-4"
|
className="font-medium underline underline-offset-4"
|
||||||
>
|
>
|
||||||
Atlassian Account Settings
|
Atlassian Account Settings
|
||||||
</a>
|
</a>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
<FormField
|
||||||
className="space-y-6"
|
control={form.control}
|
||||||
>
|
name="name"
|
||||||
<FormField
|
render={({ field }) => (
|
||||||
control={form.control}
|
<FormItem>
|
||||||
name="name"
|
<FormLabel>Connector Name</FormLabel>
|
||||||
render={({ field }) => (
|
<FormControl>
|
||||||
<FormItem>
|
<Input placeholder="My Confluence Connector" {...field} />
|
||||||
<FormLabel>Connector Name</FormLabel>
|
</FormControl>
|
||||||
<FormControl>
|
<FormDescription>
|
||||||
<Input placeholder="My Confluence Connector" {...field} />
|
A friendly name to identify this connector.
|
||||||
</FormControl>
|
</FormDescription>
|
||||||
<FormDescription>
|
<FormMessage />
|
||||||
A friendly name to identify this connector.
|
</FormItem>
|
||||||
</FormDescription>
|
)}
|
||||||
<FormMessage />
|
/>
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="base_url"
|
name="base_url"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Confluence Instance URL</FormLabel>
|
<FormLabel>Confluence Instance URL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input placeholder="https://yourcompany.atlassian.net" {...field} />
|
||||||
placeholder="https://yourcompany.atlassian.net"
|
</FormControl>
|
||||||
{...field}
|
<FormDescription>
|
||||||
/>
|
Your Confluence instance URL. For Atlassian Cloud, this is typically
|
||||||
</FormControl>
|
https://yourcompany.atlassian.net
|
||||||
<FormDescription>
|
</FormDescription>
|
||||||
Your Confluence instance URL. For Atlassian Cloud, this is
|
<FormMessage />
|
||||||
typically https://yourcompany.atlassian.net
|
</FormItem>
|
||||||
</FormDescription>
|
)}
|
||||||
<FormMessage />
|
/>
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email Address</FormLabel>
|
<FormLabel>Email Address</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input type="email" placeholder="your.email@company.com" {...field} />
|
||||||
type="email"
|
</FormControl>
|
||||||
placeholder="your.email@company.com"
|
<FormDescription>Your Atlassian account email address.</FormDescription>
|
||||||
{...field}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
</FormControl>
|
)}
|
||||||
<FormDescription>
|
/>
|
||||||
Your Atlassian account email address.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="api_token"
|
name="api_token"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>API Token</FormLabel>
|
<FormLabel>API Token</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Your Confluence API Token"
|
placeholder="Your Confluence API Token"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Your Confluence API Token will be encrypted and stored securely.
|
Your Confluence API Token will be encrypted and stored securely.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||||
type="submit"
|
{isSubmitting ? (
|
||||||
disabled={isSubmitting}
|
<>
|
||||||
className="w-full sm:w-auto"
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
>
|
Connecting...
|
||||||
{isSubmitting ? (
|
</>
|
||||||
<>
|
) : (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<>
|
||||||
Connecting...
|
<Check className="mr-2 h-4 w-4" />
|
||||||
</>
|
Connect Confluence
|
||||||
) : (
|
</>
|
||||||
<>
|
)}
|
||||||
<Check className="mr-2 h-4 w-4" />
|
</Button>
|
||||||
Connect Confluence
|
</div>
|
||||||
</>
|
</form>
|
||||||
)}
|
</Form>
|
||||||
</Button>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</form>
|
</TabsContent>
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="documentation">
|
<TabsContent value="documentation">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Confluence Integration Guide</CardTitle>
|
<CardTitle>Confluence Integration Guide</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Learn how to set up and use the Confluence connector.
|
Learn how to set up and use the Confluence connector.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-3">What gets indexed?</h3>
|
<h3 className="text-lg font-semibold mb-3">What gets indexed?</h3>
|
||||||
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
|
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
|
||||||
<li>All pages from accessible spaces</li>
|
<li>All pages from accessible spaces</li>
|
||||||
<li>Page content and metadata</li>
|
<li>Page content and metadata</li>
|
||||||
<li>Comments on pages (both footer and inline comments)</li>
|
<li>Comments on pages (both footer and inline comments)</li>
|
||||||
<li>Page titles and descriptions</li>
|
<li>Page titles and descriptions</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-3">Setup Instructions</h3>
|
<h3 className="text-lg font-semibold mb-3">Setup Instructions</h3>
|
||||||
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
|
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
|
||||||
<li>Go to your Atlassian Account Settings</li>
|
<li>Go to your Atlassian Account Settings</li>
|
||||||
<li>Navigate to Security → API tokens</li>
|
<li>Navigate to Security → API tokens</li>
|
||||||
<li>Create a new API token with appropriate permissions</li>
|
<li>Create a new API token with appropriate permissions</li>
|
||||||
<li>Copy the token and paste it in the form above</li>
|
<li>Copy the token and paste it in the form above</li>
|
||||||
<li>Ensure your account has read access to the spaces you want to index</li>
|
<li>Ensure your account has read access to the spaces you want to index</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-3">Permissions Required</h3>
|
<h3 className="text-lg font-semibold mb-3">Permissions Required</h3>
|
||||||
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
|
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
|
||||||
<li>Read access to Confluence spaces</li>
|
<li>Read access to Confluence spaces</li>
|
||||||
<li>View pages and comments</li>
|
<li>View pages and comments</li>
|
||||||
<li>Access to space metadata</li>
|
<li>Access to space metadata</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Alert>
|
<Alert>
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
The connector will only index content that your account has permission to view.
|
The connector will only index content that your account has permission to view.
|
||||||
Make sure your API token has the necessary permissions for the spaces you want to index.
|
Make sure your API token has the necessary permissions for the spaces you want
|
||||||
</AlertDescription>
|
to index.
|
||||||
</Alert>
|
</AlertDescription>
|
||||||
</CardContent>
|
</Alert>
|
||||||
</Card>
|
</CardContent>
|
||||||
</TabsContent>
|
</Card>
|
||||||
</Tabs>
|
</TabsContent>
|
||||||
</motion.div>
|
</Tabs>
|
||||||
</div>
|
</motion.div>
|
||||||
);
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,315 +1,345 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter, useParams } from "next/navigation";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { motion } from "framer-motion";
|
||||||
import * as z from "zod";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import * as z from "zod";
|
||||||
import {
|
import {
|
||||||
Form,
|
Accordion,
|
||||||
FormControl,
|
AccordionContent,
|
||||||
FormDescription,
|
AccordionItem,
|
||||||
FormField,
|
AccordionTrigger,
|
||||||
FormItem,
|
} from "@/components/ui/accordion";
|
||||||
FormLabel,
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Form,
|
||||||
AlertDescription,
|
FormControl,
|
||||||
AlertTitle,
|
FormDescription,
|
||||||
} from "@/components/ui/alert";
|
FormField,
|
||||||
import {
|
FormItem,
|
||||||
Accordion,
|
FormLabel,
|
||||||
AccordionContent,
|
FormMessage,
|
||||||
AccordionItem,
|
} from "@/components/ui/form";
|
||||||
AccordionTrigger,
|
import { Input } from "@/components/ui/input";
|
||||||
} from "@/components/ui/accordion";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||||
|
|
||||||
// Define the form schema with Zod
|
// Define the form schema with Zod
|
||||||
const discordConnectorFormSchema = z.object({
|
const discordConnectorFormSchema = z.object({
|
||||||
name: z.string().min(3, {
|
name: z.string().min(3, {
|
||||||
message: "Connector name must be at least 3 characters.",
|
message: "Connector name must be at least 3 characters.",
|
||||||
}),
|
}),
|
||||||
bot_token: z.string()
|
bot_token: z
|
||||||
.min(50, { message: "Discord Bot Token appears to be too short." })
|
.string()
|
||||||
.regex(/^[A-Za-z0-9._-]+$/, { message: "Discord Bot Token contains invalid characters." }),
|
.min(50, { message: "Discord Bot Token appears to be too short." })
|
||||||
|
.regex(/^[A-Za-z0-9._-]+$/, { message: "Discord Bot Token contains invalid characters." }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Define the type for the form values
|
// Define the type for the form values
|
||||||
type DiscordConnectorFormValues = z.infer<typeof discordConnectorFormSchema>;
|
type DiscordConnectorFormValues = z.infer<typeof discordConnectorFormSchema>;
|
||||||
|
|
||||||
export default function DiscordConnectorPage() {
|
export default function DiscordConnectorPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchSpaceId = params.search_space_id as string;
|
const searchSpaceId = params.search_space_id as string;
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { createConnector } = useSearchSourceConnectors();
|
const { createConnector } = useSearchSourceConnectors();
|
||||||
|
|
||||||
// Initialize the form
|
// Initialize the form
|
||||||
const form = useForm<DiscordConnectorFormValues>({
|
const form = useForm<DiscordConnectorFormValues>({
|
||||||
resolver: zodResolver(discordConnectorFormSchema),
|
resolver: zodResolver(discordConnectorFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "Discord Connector",
|
name: "Discord Connector",
|
||||||
bot_token: "",
|
bot_token: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
const onSubmit = async (values: DiscordConnectorFormValues) => {
|
const onSubmit = async (values: DiscordConnectorFormValues) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await createConnector({
|
await createConnector({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
connector_type: "DISCORD_CONNECTOR",
|
connector_type: "DISCORD_CONNECTOR",
|
||||||
config: {
|
config: {
|
||||||
DISCORD_BOT_TOKEN: values.bot_token,
|
DISCORD_BOT_TOKEN: values.bot_token,
|
||||||
},
|
},
|
||||||
is_indexable: true,
|
is_indexable: true,
|
||||||
last_indexed_at: null,
|
last_indexed_at: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Discord connector created successfully!");
|
toast.success("Discord connector created successfully!");
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating connector:", error);
|
console.error("Error creating connector:", error);
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 max-w-3xl">
|
<div className="container mx-auto py-8 max-w-3xl">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="mb-6"
|
className="mb-6"
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
Back to Connectors
|
Back to Connectors
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
>
|
>
|
||||||
<Tabs defaultValue="connect" className="w-full">
|
<Tabs defaultValue="connect" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="connect">
|
|
||||||
<Card className="border-2 border-border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-2xl font-bold">Connect Discord Server</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Integrate with Discord to search and retrieve information from your servers and channels. This connector can index your Discord messages for search.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Alert className="mb-6 bg-muted">
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<AlertTitle>Bot Token Required</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
You'll need a Discord Bot Token to use this connector. You can create a Discord bot and get the token from the{" "}
|
|
||||||
<a
|
|
||||||
href="https://discord.com/developers/applications"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-medium underline underline-offset-4"
|
|
||||||
>
|
|
||||||
Discord Developer Portal
|
|
||||||
</a>.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Form {...form}>
|
<TabsContent value="connect">
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
<Card className="border-2 border-border">
|
||||||
<FormField
|
<CardHeader>
|
||||||
control={form.control}
|
<CardTitle className="text-2xl font-bold">Connect Discord Server</CardTitle>
|
||||||
name="name"
|
<CardDescription>
|
||||||
render={({ field }) => (
|
Integrate with Discord to search and retrieve information from your servers and
|
||||||
<FormItem>
|
channels. This connector can index your Discord messages for search.
|
||||||
<FormLabel>Connector Name</FormLabel>
|
</CardDescription>
|
||||||
<FormControl>
|
</CardHeader>
|
||||||
<Input placeholder="My Discord Connector" {...field} />
|
<CardContent>
|
||||||
</FormControl>
|
<Alert className="mb-6 bg-muted">
|
||||||
<FormDescription>
|
<Info className="h-4 w-4" />
|
||||||
A friendly name to identify this connector.
|
<AlertTitle>Bot Token Required</AlertTitle>
|
||||||
</FormDescription>
|
<AlertDescription>
|
||||||
<FormMessage />
|
You'll need a Discord Bot Token to use this connector. You can create a Discord
|
||||||
</FormItem>
|
bot and get the token from the{" "}
|
||||||
)}
|
<a
|
||||||
/>
|
href="https://discord.com/developers/applications"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Discord Developer Portal
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<FormField
|
<Form {...form}>
|
||||||
control={form.control}
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
name="bot_token"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="name"
|
||||||
<FormLabel>Discord Bot Token</FormLabel>
|
render={({ field }) => (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Input
|
<FormLabel>Connector Name</FormLabel>
|
||||||
type="password"
|
<FormControl>
|
||||||
placeholder="Bot Token..."
|
<Input placeholder="My Discord Connector" {...field} />
|
||||||
{...field}
|
</FormControl>
|
||||||
/>
|
<FormDescription>
|
||||||
</FormControl>
|
A friendly name to identify this connector.
|
||||||
<FormDescription>
|
</FormDescription>
|
||||||
Your Discord Bot Token will be encrypted and stored securely. You can find it in the Bot section of your application in the Discord Developer Portal.
|
<FormMessage />
|
||||||
</FormDescription>
|
</FormItem>
|
||||||
<FormMessage />
|
)}
|
||||||
</FormItem>
|
/>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<FormField
|
||||||
<Button
|
control={form.control}
|
||||||
type="submit"
|
name="bot_token"
|
||||||
disabled={isSubmitting}
|
render={({ field }) => (
|
||||||
className="w-full sm:w-auto"
|
<FormItem>
|
||||||
>
|
<FormLabel>Discord Bot Token</FormLabel>
|
||||||
{isSubmitting ? (
|
<FormControl>
|
||||||
<>
|
<Input type="password" placeholder="Bot Token..." {...field} />
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
</FormControl>
|
||||||
Connecting...
|
<FormDescription>
|
||||||
</>
|
Your Discord Bot Token will be encrypted and stored securely. You can
|
||||||
) : (
|
find it in the Bot section of your application in the Discord Developer
|
||||||
<>
|
Portal.
|
||||||
<Check className="mr-2 h-4 w-4" />
|
</FormDescription>
|
||||||
Connect Discord
|
<FormMessage />
|
||||||
</>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
|
||||||
<h4 className="text-sm font-medium">What you get with Discord integration:</h4>
|
|
||||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
|
||||||
<li>Search through your Discord servers and channels</li>
|
|
||||||
<li>Access historical messages and shared files</li>
|
|
||||||
<li>Connect your team's knowledge directly to your search space</li>
|
|
||||||
<li>Keep your search results up-to-date with latest communications</li>
|
|
||||||
<li>Index your Discord messages for enhanced search capabilities</li>
|
|
||||||
</ul>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="documentation">
|
|
||||||
<Card className="border-2 border-border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-2xl font-bold">Discord Connector Documentation</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Learn how to set up and use the Discord connector to index your server data.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
The Discord connector indexes all accessible channels for a given bot in your servers.
|
|
||||||
</p>
|
|
||||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
|
||||||
<li>Upcoming: Support for private channels by granting the bot access.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Accordion type="single" collapsible className="w-full">
|
|
||||||
<AccordionItem value="authorization">
|
|
||||||
<AccordionTrigger className="text-lg font-medium">Authorization</AccordionTrigger>
|
|
||||||
<AccordionContent className="space-y-4">
|
|
||||||
<Alert className="bg-muted">
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<AlertTitle>Bot Setup Required</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
You must create a Discord bot and add it to your server with the correct permissions.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<ol className="list-decimal pl-5 space-y-3">
|
|
||||||
<li>Go to <a href="https://discord.com/developers/applications" target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-4">https://discord.com/developers/applications</a>.</li>
|
|
||||||
<li>Create a new application and add a bot to it.</li>
|
|
||||||
<li>Copy the Bot Token from the Bot section.</li>
|
|
||||||
<li>Invite the bot to your server with the following OAuth2 scopes and permissions:
|
|
||||||
<ul className="list-disc pl-5 mt-1">
|
|
||||||
<li>Scopes: <code>bot</code></li>
|
|
||||||
<li>Bot Permissions: <code>Read Messages/View Channels</code>, <code>Read Message History</code>, <code>Send Messages</code></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>Paste the Bot Token above to connect.</li>
|
|
||||||
</ol>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem value="indexing">
|
|
||||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
|
||||||
<AccordionContent className="space-y-4">
|
|
||||||
<ol className="list-decimal pl-5 space-y-3">
|
|
||||||
<li>Navigate to the Connector Dashboard and select the <strong>Discord</strong> Connector.</li>
|
|
||||||
<li>Place the <strong>Bot Token</strong> under <strong>Step 1 Provide Credentials</strong>.</li>
|
|
||||||
<li>Click <strong>Connect</strong> to establish the connection.</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<Alert className="bg-muted">
|
<div className="flex justify-end">
|
||||||
<Info className="h-4 w-4" />
|
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||||
<AlertTitle>Important: Bot Channel Access</AlertTitle>
|
{isSubmitting ? (
|
||||||
<AlertDescription>
|
<>
|
||||||
After connecting, ensure the bot has access to all channels you want to index. You may need to adjust channel permissions in Discord.
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
</AlertDescription>
|
Connecting...
|
||||||
</Alert>
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check className="mr-2 h-4 w-4" />
|
||||||
|
Connect Discord
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||||
|
<h4 className="text-sm font-medium">What you get with Discord integration:</h4>
|
||||||
|
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||||
|
<li>Search through your Discord servers and channels</li>
|
||||||
|
<li>Access historical messages and shared files</li>
|
||||||
|
<li>Connect your team's knowledge directly to your search space</li>
|
||||||
|
<li>Keep your search results up-to-date with latest communications</li>
|
||||||
|
<li>Index your Discord messages for enhanced search capabilities</li>
|
||||||
|
</ul>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<Alert className="bg-muted mt-4">
|
<TabsContent value="documentation">
|
||||||
<Info className="h-4 w-4" />
|
<Card className="border-2 border-border">
|
||||||
<AlertTitle>First Indexing</AlertTitle>
|
<CardHeader>
|
||||||
<AlertDescription>
|
<CardTitle className="text-2xl font-bold">
|
||||||
The first indexing pulls all accessible channels and may take longer than future updates. Only channels where the bot has access will be indexed.
|
Discord Connector Documentation
|
||||||
</AlertDescription>
|
</CardTitle>
|
||||||
</Alert>
|
<CardDescription>
|
||||||
|
Learn how to set up and use the Discord connector to index your server data.
|
||||||
<div className="mt-4">
|
</CardDescription>
|
||||||
<h4 className="font-medium mb-2">Troubleshooting:</h4>
|
</CardHeader>
|
||||||
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
|
<CardContent className="space-y-6">
|
||||||
<li>
|
<div>
|
||||||
<strong>Missing messages:</strong> If you don't see messages from a channel, check the bot's permissions for that channel.
|
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||||
</li>
|
<p className="text-muted-foreground">
|
||||||
<li>
|
The Discord connector indexes all accessible channels for a given bot in your
|
||||||
<strong>Bot not responding:</strong> Make sure the bot is online and the token is correct.
|
servers.
|
||||||
</li>
|
</p>
|
||||||
<li>
|
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||||
<strong>Private channels:</strong> The bot must be explicitly granted access to private channels.
|
<li>Upcoming: Support for private channels by granting the bot access.</li>
|
||||||
</li>
|
</ul>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
<Accordion type="single" collapsible className="w-full">
|
||||||
</AccordionItem>
|
<AccordionItem value="authorization">
|
||||||
</Accordion>
|
<AccordionTrigger className="text-lg font-medium">
|
||||||
</CardContent>
|
Authorization
|
||||||
</Card>
|
</AccordionTrigger>
|
||||||
</TabsContent>
|
<AccordionContent className="space-y-4">
|
||||||
</Tabs>
|
<Alert className="bg-muted">
|
||||||
</motion.div>
|
<Info className="h-4 w-4" />
|
||||||
</div>
|
<AlertTitle>Bot Setup Required</AlertTitle>
|
||||||
);
|
<AlertDescription>
|
||||||
|
You must create a Discord bot and add it to your server with the correct
|
||||||
|
permissions.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<ol className="list-decimal pl-5 space-y-3">
|
||||||
|
<li>
|
||||||
|
Go to{" "}
|
||||||
|
<a
|
||||||
|
href="https://discord.com/developers/applications"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
https://discord.com/developers/applications
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</li>
|
||||||
|
<li>Create a new application and add a bot to it.</li>
|
||||||
|
<li>Copy the Bot Token from the Bot section.</li>
|
||||||
|
<li>
|
||||||
|
Invite the bot to your server with the following OAuth2 scopes and
|
||||||
|
permissions:
|
||||||
|
<ul className="list-disc pl-5 mt-1">
|
||||||
|
<li>
|
||||||
|
Scopes: <code>bot</code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Bot Permissions: <code>Read Messages/View Channels</code>,{" "}
|
||||||
|
<code>Read Message History</code>, <code>Send Messages</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Paste the Bot Token above to connect.</li>
|
||||||
|
</ol>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="indexing">
|
||||||
|
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4">
|
||||||
|
<ol className="list-decimal pl-5 space-y-3">
|
||||||
|
<li>
|
||||||
|
Navigate to the Connector Dashboard and select the{" "}
|
||||||
|
<strong>Discord</strong> Connector.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Place the <strong>Bot Token</strong> under{" "}
|
||||||
|
<strong>Step 1 Provide Credentials</strong>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Click <strong>Connect</strong> to establish the connection.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<Alert className="bg-muted">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>Important: Bot Channel Access</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
After connecting, ensure the bot has access to all channels you want to
|
||||||
|
index. You may need to adjust channel permissions in Discord.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Alert className="bg-muted mt-4">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>First Indexing</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
The first indexing pulls all accessible channels and may take longer than
|
||||||
|
future updates. Only channels where the bot has access will be indexed.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="font-medium mb-2">Troubleshooting:</h4>
|
||||||
|
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
|
||||||
|
<li>
|
||||||
|
<strong>Missing messages:</strong> If you don't see messages from a
|
||||||
|
channel, check the bot's permissions for that channel.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Bot not responding:</strong> Make sure the bot is online and the
|
||||||
|
token is correct.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Private channels:</strong> The bot must be explicitly granted
|
||||||
|
access to private channels.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,59 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter, useParams } from "next/navigation";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ArrowLeft, Check, CircleAlert, Github, Info, ListChecks, Loader2 } from "lucide-react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import * as z from "zod";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ArrowLeft, Check, Info, Loader2, Github, CircleAlert, ListChecks } from "lucide-react";
|
import * as z from "zod";
|
||||||
|
|
||||||
// Assuming useSearchSourceConnectors hook exists and works similarly
|
|
||||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Accordion,
|
||||||
FormControl,
|
AccordionContent,
|
||||||
FormDescription,
|
AccordionItem,
|
||||||
FormField,
|
AccordionTrigger,
|
||||||
FormItem,
|
} from "@/components/ui/accordion";
|
||||||
FormLabel,
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
AlertDescription,
|
|
||||||
AlertTitle,
|
|
||||||
} from "@/components/ui/alert";
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from "@/components/ui/accordion";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
// Assuming useSearchSourceConnectors hook exists and works similarly
|
||||||
|
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||||
|
|
||||||
// Define the form schema with Zod for GitHub PAT entry step
|
// Define the form schema with Zod for GitHub PAT entry step
|
||||||
const githubPatFormSchema = z.object({
|
const githubPatFormSchema = z.object({
|
||||||
name: z.string().min(3, {
|
name: z.string().min(3, {
|
||||||
message: "Connector name must be at least 3 characters.",
|
message: "Connector name must be at least 3 characters.",
|
||||||
}),
|
}),
|
||||||
github_pat: z.string()
|
github_pat: z
|
||||||
.min(20, { // Apply min length first
|
.string()
|
||||||
message: "GitHub Personal Access Token seems too short.",
|
.min(20, {
|
||||||
})
|
// Apply min length first
|
||||||
.refine(pat => pat.startsWith('ghp_') || pat.startsWith('github_pat_'), { // Then refine the pattern
|
message: "GitHub Personal Access Token seems too short.",
|
||||||
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'",
|
})
|
||||||
}),
|
.refine((pat) => pat.startsWith("ghp_") || pat.startsWith("github_pat_"), {
|
||||||
|
// Then refine the pattern
|
||||||
|
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Define the type for the form values
|
// Define the type for the form values
|
||||||
|
|
@ -63,394 +61,468 @@ type GithubPatFormValues = z.infer<typeof githubPatFormSchema>;
|
||||||
|
|
||||||
// Type for fetched GitHub repositories
|
// Type for fetched GitHub repositories
|
||||||
interface GithubRepo {
|
interface GithubRepo {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
private: boolean;
|
private: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
last_updated: string | null;
|
last_updated: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GithubConnectorPage() {
|
export default function GithubConnectorPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchSpaceId = params.search_space_id as string;
|
const searchSpaceId = params.search_space_id as string;
|
||||||
const [step, setStep] = useState<'enter_pat' | 'select_repos'>('enter_pat');
|
const [step, setStep] = useState<"enter_pat" | "select_repos">("enter_pat");
|
||||||
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
|
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
|
||||||
const [isCreatingConnector, setIsCreatingConnector] = useState(false);
|
const [isCreatingConnector, setIsCreatingConnector] = useState(false);
|
||||||
const [repositories, setRepositories] = useState<GithubRepo[]>([]);
|
const [repositories, setRepositories] = useState<GithubRepo[]>([]);
|
||||||
const [selectedRepos, setSelectedRepos] = useState<string[]>([]);
|
const [selectedRepos, setSelectedRepos] = useState<string[]>([]);
|
||||||
const [connectorName, setConnectorName] = useState<string>("GitHub Connector");
|
const [connectorName, setConnectorName] = useState<string>("GitHub Connector");
|
||||||
const [validatedPat, setValidatedPat] = useState<string>(""); // Store the validated PAT
|
const [validatedPat, setValidatedPat] = useState<string>(""); // Store the validated PAT
|
||||||
|
|
||||||
const { createConnector } = useSearchSourceConnectors();
|
const { createConnector } = useSearchSourceConnectors();
|
||||||
|
|
||||||
// Initialize the form for PAT entry
|
// Initialize the form for PAT entry
|
||||||
const form = useForm<GithubPatFormValues>({
|
const form = useForm<GithubPatFormValues>({
|
||||||
resolver: zodResolver(githubPatFormSchema),
|
resolver: zodResolver(githubPatFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: connectorName,
|
name: connectorName,
|
||||||
github_pat: "",
|
github_pat: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Function to fetch repositories using the new backend endpoint
|
// Function to fetch repositories using the new backend endpoint
|
||||||
const fetchRepositories = async (values: GithubPatFormValues) => {
|
const fetchRepositories = async (values: GithubPatFormValues) => {
|
||||||
setIsFetchingRepos(true);
|
setIsFetchingRepos(true);
|
||||||
setConnectorName(values.name); // Store the name
|
setConnectorName(values.name); // Store the name
|
||||||
setValidatedPat(values.github_pat); // Store the PAT temporarily
|
setValidatedPat(values.github_pat); // Store the PAT temporarily
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('surfsense_bearer_token');
|
const token = localStorage.getItem("surfsense_bearer_token");
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('No authentication token found');
|
throw new Error("No authentication token found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories/`,
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories/`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'Authorization': `Bearer ${token}`
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ github_pat: values.github_pat })
|
body: JSON.stringify({ github_pat: values.github_pat }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
throw new Error(errorData.detail || `Failed to fetch repositories: ${response.statusText}`);
|
throw new Error(errorData.detail || `Failed to fetch repositories: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: GithubRepo[] = await response.json();
|
const data: GithubRepo[] = await response.json();
|
||||||
setRepositories(data);
|
setRepositories(data);
|
||||||
setStep('select_repos'); // Move to the next step
|
setStep("select_repos"); // Move to the next step
|
||||||
toast.success(`Found ${data.length} repositories.`);
|
toast.success(`Found ${data.length} repositories.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching GitHub repositories:", error);
|
console.error("Error fetching GitHub repositories:", error);
|
||||||
const errorMessage = error instanceof Error ? error.message : "Failed to fetch repositories. Please check the PAT and try again.";
|
const errorMessage =
|
||||||
toast.error(errorMessage);
|
error instanceof Error
|
||||||
} finally {
|
? error.message
|
||||||
setIsFetchingRepos(false);
|
: "Failed to fetch repositories. Please check the PAT and try again.";
|
||||||
}
|
toast.error(errorMessage);
|
||||||
};
|
} finally {
|
||||||
|
setIsFetchingRepos(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle final connector creation
|
// Handle final connector creation
|
||||||
const handleCreateConnector = async () => {
|
const handleCreateConnector = async () => {
|
||||||
if (selectedRepos.length === 0) {
|
if (selectedRepos.length === 0) {
|
||||||
toast.warning("Please select at least one repository to index.");
|
toast.warning("Please select at least one repository to index.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsCreatingConnector(true);
|
setIsCreatingConnector(true);
|
||||||
try {
|
try {
|
||||||
await createConnector({
|
await createConnector({
|
||||||
name: connectorName, // Use the stored name
|
name: connectorName, // Use the stored name
|
||||||
connector_type: "GITHUB_CONNECTOR",
|
connector_type: "GITHUB_CONNECTOR",
|
||||||
config: {
|
config: {
|
||||||
GITHUB_PAT: validatedPat, // Use the stored validated PAT
|
GITHUB_PAT: validatedPat, // Use the stored validated PAT
|
||||||
repo_full_names: selectedRepos, // Add the selected repo names
|
repo_full_names: selectedRepos, // Add the selected repo names
|
||||||
},
|
},
|
||||||
is_indexable: true,
|
is_indexable: true,
|
||||||
last_indexed_at: null,
|
last_indexed_at: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("GitHub connector created successfully!");
|
toast.success("GitHub connector created successfully!");
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating GitHub connector:", error);
|
console.error("Error creating GitHub connector:", error);
|
||||||
const errorMessage = error instanceof Error ? error.message : "Failed to create GitHub connector.";
|
const errorMessage =
|
||||||
toast.error(errorMessage);
|
error instanceof Error ? error.message : "Failed to create GitHub connector.";
|
||||||
} finally {
|
toast.error(errorMessage);
|
||||||
setIsCreatingConnector(false);
|
} finally {
|
||||||
}
|
setIsCreatingConnector(false);
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle checkbox changes
|
// Handle checkbox changes
|
||||||
const handleRepoSelection = (repoFullName: string, checked: boolean) => {
|
const handleRepoSelection = (repoFullName: string, checked: boolean) => {
|
||||||
setSelectedRepos(prev =>
|
setSelectedRepos((prev) =>
|
||||||
checked
|
checked ? [...prev, repoFullName] : prev.filter((name) => name !== repoFullName)
|
||||||
? [...prev, repoFullName]
|
);
|
||||||
: prev.filter(name => name !== repoFullName)
|
};
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 max-w-3xl">
|
<div className="container mx-auto py-8 max-w-3xl">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="mb-6"
|
className="mb-6"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (step === 'select_repos') {
|
if (step === "select_repos") {
|
||||||
// Go back to PAT entry, clear sensitive/fetched data
|
// Go back to PAT entry, clear sensitive/fetched data
|
||||||
setStep('enter_pat');
|
setStep("enter_pat");
|
||||||
setRepositories([]);
|
setRepositories([]);
|
||||||
setSelectedRepos([]);
|
setSelectedRepos([]);
|
||||||
setValidatedPat("");
|
setValidatedPat("");
|
||||||
// Reset form PAT field, keep name
|
// Reset form PAT field, keep name
|
||||||
form.reset({ name: connectorName, github_pat: "" });
|
form.reset({ name: connectorName, github_pat: "" });
|
||||||
} else {
|
} else {
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors/add`);
|
router.push(`/dashboard/${searchSpaceId}/connectors/add`);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
{step === 'select_repos' ? "Back to PAT Entry" : "Back to Add Connectors"}
|
{step === "select_repos" ? "Back to PAT Entry" : "Back to Add Connectors"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
>
|
>
|
||||||
<Tabs defaultValue="connect" className="w-full">
|
<Tabs defaultValue="connect" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||||
<TabsTrigger value="connect">Connect GitHub</TabsTrigger>
|
<TabsTrigger value="connect">Connect GitHub</TabsTrigger>
|
||||||
<TabsTrigger value="documentation">Setup Guide</TabsTrigger>
|
<TabsTrigger value="documentation">Setup Guide</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="connect">
|
<TabsContent value="connect">
|
||||||
<Card className="border-2 border-border">
|
<Card className="border-2 border-border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||||
{step === 'enter_pat' ? <Github className="h-6 w-6" /> : <ListChecks className="h-6 w-6" />}
|
{step === "enter_pat" ? (
|
||||||
{step === 'enter_pat' ? "Connect GitHub Account" : "Select Repositories to Index"}
|
<Github className="h-6 w-6" />
|
||||||
</CardTitle>
|
) : (
|
||||||
<CardDescription>
|
<ListChecks className="h-6 w-6" />
|
||||||
{step === 'enter_pat'
|
)}
|
||||||
? "Provide a name and GitHub Personal Access Token (PAT) to fetch accessible repositories."
|
{step === "enter_pat" ? "Connect GitHub Account" : "Select Repositories to Index"}
|
||||||
: `Select which repositories you want SurfSense to index for search. Found ${repositories.length} repositories accessible via your PAT.`
|
</CardTitle>
|
||||||
}
|
<CardDescription>
|
||||||
</CardDescription>
|
{step === "enter_pat"
|
||||||
</CardHeader>
|
? "Provide a name and GitHub Personal Access Token (PAT) to fetch accessible repositories."
|
||||||
|
: `Select which repositories you want SurfSense to index for search. Found ${repositories.length} repositories accessible via your PAT.`}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
{step === 'enter_pat' && (
|
{step === "enter_pat" && (
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Alert className="mb-6 bg-muted">
|
<Alert className="mb-6 bg-muted">
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
<AlertTitle>GitHub Personal Access Token (PAT) Required</AlertTitle>
|
<AlertTitle>GitHub Personal Access Token (PAT) Required</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to fetch repositories. You can create one from your{' '}
|
You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to fetch
|
||||||
<a
|
repositories. You can create one from your{" "}
|
||||||
href="https://github.com/settings/personal-access-tokens"
|
<a
|
||||||
target="_blank"
|
href="https://github.com/settings/personal-access-tokens"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="font-medium underline underline-offset-4"
|
rel="noopener noreferrer"
|
||||||
>
|
className="font-medium underline underline-offset-4"
|
||||||
GitHub Developer Settings
|
>
|
||||||
</a>. The PAT will be used to fetch repositories and then stored securely to enable indexing.
|
GitHub Developer Settings
|
||||||
</AlertDescription>
|
</a>
|
||||||
</Alert>
|
. The PAT will be used to fetch repositories and then stored securely to
|
||||||
|
enable indexing.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit(fetchRepositories)} className="space-y-6">
|
<form onSubmit={form.handleSubmit(fetchRepositories)} className="space-y-6">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Connector Name</FormLabel>
|
<FormLabel>Connector Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="My GitHub Connector" {...field} />
|
<Input placeholder="My GitHub Connector" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
A friendly name to identify this GitHub connection.
|
A friendly name to identify this GitHub connection.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="github_pat"
|
name="github_pat"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>GitHub Personal Access Token (PAT)</FormLabel>
|
<FormLabel>GitHub Personal Access Token (PAT)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="ghp_... or github_pat_..."
|
placeholder="ghp_... or github_pat_..."
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Enter your GitHub PAT here to fetch your repositories. It will be stored encrypted later.
|
Enter your GitHub PAT here to fetch your repositories. It will be
|
||||||
</FormDescription>
|
stored encrypted later.
|
||||||
<FormMessage />
|
</FormDescription>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isFetchingRepos}
|
disabled={isFetchingRepos}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{isFetchingRepos ? (
|
{isFetchingRepos ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Fetching Repositories...
|
Fetching Repositories...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Fetch Repositories"
|
"Fetch Repositories"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 'select_repos' && (
|
{step === "select_repos" && (
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{repositories.length === 0 ? (
|
{repositories.length === 0 ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<CircleAlert className="h-4 w-4" />
|
<CircleAlert className="h-4 w-4" />
|
||||||
<AlertTitle>No Repositories Found</AlertTitle>
|
<AlertTitle>No Repositories Found</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
No repositories were found or accessible with the provided PAT. Please check the token and its permissions, then go back and try again.
|
No repositories were found or accessible with the provided PAT. Please
|
||||||
</AlertDescription>
|
check the token and its permissions, then go back and try again.
|
||||||
</Alert>
|
</AlertDescription>
|
||||||
) : (
|
</Alert>
|
||||||
<div className="space-y-4">
|
) : (
|
||||||
<FormLabel>Repositories ({selectedRepos.length} selected)</FormLabel>
|
<div className="space-y-4">
|
||||||
<div className="h-64 w-full rounded-md border p-4 overflow-y-auto">
|
<FormLabel>Repositories ({selectedRepos.length} selected)</FormLabel>
|
||||||
{repositories.map((repo) => (
|
<div className="h-64 w-full rounded-md border p-4 overflow-y-auto">
|
||||||
<div key={repo.id} className="flex items-center space-x-2 mb-2 py-1">
|
{repositories.map((repo) => (
|
||||||
<Checkbox
|
<div key={repo.id} className="flex items-center space-x-2 mb-2 py-1">
|
||||||
id={`repo-${repo.id}`}
|
<Checkbox
|
||||||
checked={selectedRepos.includes(repo.full_name)}
|
id={`repo-${repo.id}`}
|
||||||
onCheckedChange={(checked) => handleRepoSelection(repo.full_name, !!checked)}
|
checked={selectedRepos.includes(repo.full_name)}
|
||||||
/>
|
onCheckedChange={(checked) =>
|
||||||
<label
|
handleRepoSelection(repo.full_name, !!checked)
|
||||||
htmlFor={`repo-${repo.id}`}
|
}
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
/>
|
||||||
>
|
<label
|
||||||
{repo.full_name} {repo.private && "(Private)"}
|
htmlFor={`repo-${repo.id}`}
|
||||||
</label>
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
</div>
|
>
|
||||||
))}
|
{repo.full_name} {repo.private && "(Private)"}
|
||||||
</div>
|
</label>
|
||||||
<FormDescription>
|
</div>
|
||||||
Select the repositories you wish to index. Only checked repositories will be processed.
|
))}
|
||||||
</FormDescription>
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
Select the repositories you wish to index. Only checked repositories will
|
||||||
|
be processed.
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
<div className="flex justify-between items-center pt-4">
|
<div className="flex justify-between items-center pt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStep('enter_pat');
|
setStep("enter_pat");
|
||||||
setRepositories([]);
|
setRepositories([]);
|
||||||
setSelectedRepos([]);
|
setSelectedRepos([]);
|
||||||
setValidatedPat("");
|
setValidatedPat("");
|
||||||
form.reset({ name: connectorName, github_pat: "" });
|
form.reset({ name: connectorName, github_pat: "" });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateConnector}
|
onClick={handleCreateConnector}
|
||||||
disabled={isCreatingConnector || selectedRepos.length === 0}
|
disabled={isCreatingConnector || selectedRepos.length === 0}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{isCreatingConnector ? (
|
{isCreatingConnector ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Creating Connector...
|
Creating Connector...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Check className="mr-2 h-4 w-4" />
|
<Check className="mr-2 h-4 w-4" />
|
||||||
Create Connector
|
Create Connector
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||||
<h4 className="text-sm font-medium">What you get with GitHub integration:</h4>
|
<h4 className="text-sm font-medium">What you get with GitHub integration:</h4>
|
||||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||||
<li>Search through code and documentation in your selected repositories</li>
|
<li>Search through code and documentation in your selected repositories</li>
|
||||||
<li>Access READMEs, Markdown files, and common code files</li>
|
<li>Access READMEs, Markdown files, and common code files</li>
|
||||||
<li>Connect your project knowledge directly to your search space</li>
|
<li>Connect your project knowledge directly to your search space</li>
|
||||||
<li>Index your selected repositories for enhanced search capabilities</li>
|
<li>Index your selected repositories for enhanced search capabilities</li>
|
||||||
</ul>
|
</ul>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="documentation">
|
<TabsContent value="documentation">
|
||||||
<Card className="border-2 border-border">
|
<Card className="border-2 border-border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl font-bold">GitHub Connector Setup Guide</CardTitle>
|
<CardTitle className="text-2xl font-bold">GitHub Connector Setup Guide</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Learn how to generate a Personal Access Token (PAT) and connect your GitHub account.
|
Learn how to generate a Personal Access Token (PAT) and connect your GitHub
|
||||||
</CardDescription>
|
account.
|
||||||
</CardHeader>
|
</CardDescription>
|
||||||
<CardContent className="space-y-6">
|
</CardHeader>
|
||||||
<div>
|
<CardContent className="space-y-6">
|
||||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
<div>
|
||||||
<p className="text-muted-foreground">
|
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||||
The GitHub connector uses a Personal Access Token (PAT) to authenticate with the GitHub API. First, it fetches a list of repositories accessible to the token. You then select which repositories you want to index. The connector indexes relevant files (code, markdown, text) from only the selected repositories.
|
<p className="text-muted-foreground">
|
||||||
</p>
|
The GitHub connector uses a Personal Access Token (PAT) to authenticate with the
|
||||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
GitHub API. First, it fetches a list of repositories accessible to the token.
|
||||||
<li>The connector indexes files based on common code and documentation extensions.</li>
|
You then select which repositories you want to index. The connector indexes
|
||||||
<li>Large files (over 1MB) are skipped during indexing.</li>
|
relevant files (code, markdown, text) from only the selected repositories.
|
||||||
<li>Only selected repositories are indexed.</li>
|
</p>
|
||||||
<li>Indexing runs periodically (check connector settings for frequency) to keep content up-to-date.</li>
|
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||||
</ul>
|
<li>
|
||||||
</div>
|
The connector indexes files based on common code and documentation extensions.
|
||||||
|
</li>
|
||||||
|
<li>Large files (over 1MB) are skipped during indexing.</li>
|
||||||
|
<li>Only selected repositories are indexed.</li>
|
||||||
|
<li>
|
||||||
|
Indexing runs periodically (check connector settings for frequency) to keep
|
||||||
|
content up-to-date.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Accordion type="single" collapsible className="w-full">
|
<Accordion type="single" collapsible className="w-full">
|
||||||
<AccordionItem value="create_pat">
|
<AccordionItem value="create_pat">
|
||||||
<AccordionTrigger className="text-lg font-medium">Step 1: Generate GitHub PAT</AccordionTrigger>
|
<AccordionTrigger className="text-lg font-medium">
|
||||||
<AccordionContent>
|
Step 1: Generate GitHub PAT
|
||||||
<div className="space-y-6">
|
</AccordionTrigger>
|
||||||
<div>
|
<AccordionContent>
|
||||||
<h4 className="font-medium mb-2">Generating a Token:</h4>
|
<div className="space-y-6">
|
||||||
<ol className="list-decimal pl-5 space-y-3">
|
<div>
|
||||||
<li>Go to your GitHub <a href="https://github.com/settings/tokens" target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-4">Developer settings</a>.</li>
|
<h4 className="font-medium mb-2">Generating a Token:</h4>
|
||||||
<li>Click on <strong>Personal access tokens</strong>, then choose <strong>Tokens (classic)</strong> or <strong>Fine-grained tokens</strong> (recommended if available and suitable).</li>
|
<ol className="list-decimal pl-5 space-y-3">
|
||||||
<li>Click <strong>Generate new token</strong> (and choose the appropriate type).</li>
|
<li>
|
||||||
<li>Give your token a descriptive name (e.g., "SurfSense Connector").</li>
|
Go to your GitHub{" "}
|
||||||
<li>Set an expiration date for the token (recommended for security).</li>
|
<a
|
||||||
<li>Under <strong>Select scopes</strong> (for classic tokens) or <strong>Repository access</strong> (for fine-grained), grant the necessary permissions. At minimum, the <strong>`repo`</strong> scope (or equivalent read access to repositories for fine-grained tokens) is required to read repository content.</li>
|
href="https://github.com/settings/tokens"
|
||||||
<li>Click <strong>Generate token</strong>.</li>
|
target="_blank"
|
||||||
<li><strong>Important:</strong> Copy your new PAT immediately. You won't be able to see it again after leaving the page.</li>
|
rel="noopener noreferrer"
|
||||||
</ol>
|
className="font-medium underline underline-offset-4"
|
||||||
</div>
|
>
|
||||||
</div>
|
Developer settings
|
||||||
</AccordionContent>
|
</a>
|
||||||
</AccordionItem>
|
.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Click on <strong>Personal access tokens</strong>, then choose{" "}
|
||||||
|
<strong>Tokens (classic)</strong> or{" "}
|
||||||
|
<strong>Fine-grained tokens</strong> (recommended if available and
|
||||||
|
suitable).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Click <strong>Generate new token</strong> (and choose the appropriate
|
||||||
|
type).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Give your token a descriptive name (e.g., "SurfSense Connector").
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Set an expiration date for the token (recommended for security).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Under <strong>Select scopes</strong> (for classic tokens) or{" "}
|
||||||
|
<strong>Repository access</strong> (for fine-grained), grant the
|
||||||
|
necessary permissions. At minimum, the <strong>`repo`</strong> scope
|
||||||
|
(or equivalent read access to repositories for fine-grained tokens) is
|
||||||
|
required to read repository content.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Click <strong>Generate token</strong>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Important:</strong> Copy your new PAT immediately. You won't
|
||||||
|
be able to see it again after leaving the page.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
<AccordionItem value="connect_app">
|
<AccordionItem value="connect_app">
|
||||||
<AccordionTrigger className="text-lg font-medium">Step 2: Connect in SurfSense</AccordionTrigger>
|
<AccordionTrigger className="text-lg font-medium">
|
||||||
<AccordionContent className="space-y-4">
|
Step 2: Connect in SurfSense
|
||||||
<ol className="list-decimal pl-5 space-y-3">
|
</AccordionTrigger>
|
||||||
<li>Navigate to the "Connect GitHub" tab.</li>
|
<AccordionContent className="space-y-4">
|
||||||
<li>Enter a name for your connector.</li>
|
<ol className="list-decimal pl-5 space-y-3">
|
||||||
<li>Paste the copied GitHub PAT into the "GitHub Personal Access Token (PAT)" field.</li>
|
<li>Navigate to the "Connect GitHub" tab.</li>
|
||||||
<li>Click <strong>Fetch Repositories</strong>.</li>
|
<li>Enter a name for your connector.</li>
|
||||||
<li>If the PAT is valid, you'll see a list of your accessible repositories.</li>
|
<li>
|
||||||
<li>Select the repositories you want SurfSense to index using the checkboxes.</li>
|
Paste the copied GitHub PAT into the "GitHub Personal Access Token (PAT)"
|
||||||
<li>Click the <strong>Create Connector</strong> button.</li>
|
field.
|
||||||
<li>If the connection is successful, you will be redirected and can start indexing from the Connectors page.</li>
|
</li>
|
||||||
</ol>
|
<li>
|
||||||
</AccordionContent>
|
Click <strong>Fetch Repositories</strong>.
|
||||||
</AccordionItem>
|
</li>
|
||||||
</Accordion>
|
<li>
|
||||||
</CardContent>
|
If the PAT is valid, you'll see a list of your accessible repositories.
|
||||||
</Card>
|
</li>
|
||||||
</TabsContent>
|
<li>
|
||||||
</Tabs>
|
Select the repositories you want SurfSense to index using the checkboxes.
|
||||||
</motion.div>
|
</li>
|
||||||
</div>
|
<li>
|
||||||
);
|
Click the <strong>Create Connector</strong> button.
|
||||||
}
|
</li>
|
||||||
|
<li>
|
||||||
|
If the connection is successful, you will be redirected and can start
|
||||||
|
indexing from the Connectors page.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,472 +1,401 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter, useParams } from "next/navigation";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { motion } from "framer-motion";
|
||||||
import * as z from "zod";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import * as z from "zod";
|
||||||
import {
|
import {
|
||||||
Form,
|
Accordion,
|
||||||
FormControl,
|
AccordionContent,
|
||||||
FormDescription,
|
AccordionItem,
|
||||||
FormField,
|
AccordionTrigger,
|
||||||
FormItem,
|
} from "@/components/ui/accordion";
|
||||||
FormLabel,
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Form,
|
||||||
AccordionContent,
|
FormControl,
|
||||||
AccordionItem,
|
FormDescription,
|
||||||
AccordionTrigger,
|
FormField,
|
||||||
} from "@/components/ui/accordion";
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||||
|
|
||||||
// Define the form schema with Zod
|
// Define the form schema with Zod
|
||||||
const jiraConnectorFormSchema = z.object({
|
const jiraConnectorFormSchema = z.object({
|
||||||
name: z.string().min(3, {
|
name: z.string().min(3, {
|
||||||
message: "Connector name must be at least 3 characters.",
|
message: "Connector name must be at least 3 characters.",
|
||||||
}),
|
}),
|
||||||
base_url: z
|
base_url: z
|
||||||
.string()
|
.string()
|
||||||
.url({
|
.url({
|
||||||
message:
|
message: "Please enter a valid Jira URL (e.g., https://yourcompany.atlassian.net)",
|
||||||
"Please enter a valid Jira URL (e.g., https://yourcompany.atlassian.net)",
|
})
|
||||||
})
|
.refine(
|
||||||
.refine(
|
(url) => {
|
||||||
(url) => {
|
return url.includes("atlassian.net") || url.includes("jira");
|
||||||
return url.includes("atlassian.net") || url.includes("jira");
|
},
|
||||||
},
|
{
|
||||||
{
|
message: "Please enter a valid Jira instance URL",
|
||||||
message: "Please enter a valid Jira instance URL",
|
}
|
||||||
},
|
),
|
||||||
),
|
email: z.string().email({
|
||||||
email: z.string().email({
|
message: "Please enter a valid email address.",
|
||||||
message: "Please enter a valid email address.",
|
}),
|
||||||
}),
|
api_token: z.string().min(10, {
|
||||||
api_token: z.string().min(10, {
|
message: "Jira API Token is required and must be valid.",
|
||||||
message: "Jira API Token is required and must be valid.",
|
}),
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Define the type for the form values
|
// Define the type for the form values
|
||||||
type JiraConnectorFormValues = z.infer<typeof jiraConnectorFormSchema>;
|
type JiraConnectorFormValues = z.infer<typeof jiraConnectorFormSchema>;
|
||||||
|
|
||||||
export default function JiraConnectorPage() {
|
export default function JiraConnectorPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchSpaceId = params.search_space_id as string;
|
const searchSpaceId = params.search_space_id as string;
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { createConnector } = useSearchSourceConnectors();
|
const { createConnector } = useSearchSourceConnectors();
|
||||||
|
|
||||||
// Initialize the form
|
// Initialize the form
|
||||||
const form = useForm<JiraConnectorFormValues>({
|
const form = useForm<JiraConnectorFormValues>({
|
||||||
resolver: zodResolver(jiraConnectorFormSchema),
|
resolver: zodResolver(jiraConnectorFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "Jira Connector",
|
name: "Jira Connector",
|
||||||
base_url: "",
|
base_url: "",
|
||||||
email: "",
|
email: "",
|
||||||
api_token: "",
|
api_token: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
const onSubmit = async (values: JiraConnectorFormValues) => {
|
const onSubmit = async (values: JiraConnectorFormValues) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await createConnector({
|
await createConnector({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
connector_type: "JIRA_CONNECTOR",
|
connector_type: "JIRA_CONNECTOR",
|
||||||
config: {
|
config: {
|
||||||
JIRA_BASE_URL: values.base_url,
|
JIRA_BASE_URL: values.base_url,
|
||||||
JIRA_EMAIL: values.email,
|
JIRA_EMAIL: values.email,
|
||||||
JIRA_API_TOKEN: values.api_token,
|
JIRA_API_TOKEN: values.api_token,
|
||||||
},
|
},
|
||||||
is_indexable: true,
|
is_indexable: true,
|
||||||
last_indexed_at: null,
|
last_indexed_at: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Jira connector created successfully!");
|
toast.success("Jira connector created successfully!");
|
||||||
|
|
||||||
// Navigate back to connectors page
|
// Navigate back to connectors page
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating connector:", error);
|
console.error("Error creating connector:", error);
|
||||||
toast.error(
|
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||||
error instanceof Error ? error.message : "Failed to create connector",
|
} finally {
|
||||||
);
|
setIsSubmitting(false);
|
||||||
} finally {
|
}
|
||||||
setIsSubmitting(false);
|
};
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 max-w-3xl">
|
<div className="container mx-auto py-8 max-w-3xl">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="mb-6"
|
className="mb-6"
|
||||||
onClick={() =>
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors/add`)
|
>
|
||||||
}
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
>
|
Back to Connectors
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
</Button>
|
||||||
Back to Connectors
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
>
|
>
|
||||||
<Tabs defaultValue="connect" className="w-full">
|
<Tabs defaultValue="connect" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="connect">
|
<TabsContent value="connect">
|
||||||
<Card className="border-2 border-border">
|
<Card className="border-2 border-border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl font-bold">
|
<CardTitle className="text-2xl font-bold">Connect Jira Instance</CardTitle>
|
||||||
Connect Jira Instance
|
<CardDescription>
|
||||||
</CardTitle>
|
Integrate with Jira to search and retrieve information from your issues, tickets,
|
||||||
<CardDescription>
|
and comments. This connector can index your Jira content for search.
|
||||||
Integrate with Jira to search and retrieve information from
|
</CardDescription>
|
||||||
your issues, tickets, and comments. This connector can index
|
</CardHeader>
|
||||||
your Jira content for search.
|
<CardContent>
|
||||||
</CardDescription>
|
<Alert className="mb-6 bg-muted">
|
||||||
</CardHeader>
|
<Info className="h-4 w-4" />
|
||||||
<CardContent>
|
<AlertTitle>Jira Personal Access Token Required</AlertTitle>
|
||||||
<Alert className="mb-6 bg-muted">
|
<AlertDescription>
|
||||||
<Info className="h-4 w-4" />
|
You'll need a Jira Personal Access Token to use this connector. You can create
|
||||||
<AlertTitle>Jira Personal Access Token Required</AlertTitle>
|
one from{" "}
|
||||||
<AlertDescription>
|
<a
|
||||||
You'll need a Jira Personal Access Token to use this
|
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||||
connector. You can create one from{" "}
|
target="_blank"
|
||||||
<a
|
rel="noopener noreferrer"
|
||||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
className="font-medium underline underline-offset-4"
|
||||||
target="_blank"
|
>
|
||||||
rel="noopener noreferrer"
|
Atlassian Account Settings
|
||||||
className="font-medium underline underline-offset-4"
|
</a>
|
||||||
>
|
</AlertDescription>
|
||||||
Atlassian Account Settings
|
</Alert>
|
||||||
</a>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
<FormField
|
||||||
className="space-y-6"
|
control={form.control}
|
||||||
>
|
name="name"
|
||||||
<FormField
|
render={({ field }) => (
|
||||||
control={form.control}
|
<FormItem>
|
||||||
name="name"
|
<FormLabel>Connector Name</FormLabel>
|
||||||
render={({ field }) => (
|
<FormControl>
|
||||||
<FormItem>
|
<Input placeholder="My Jira Connector" {...field} />
|
||||||
<FormLabel>Connector Name</FormLabel>
|
</FormControl>
|
||||||
<FormControl>
|
<FormDescription>
|
||||||
<Input placeholder="My Jira Connector" {...field} />
|
A friendly name to identify this connector.
|
||||||
</FormControl>
|
</FormDescription>
|
||||||
<FormDescription>
|
<FormMessage />
|
||||||
A friendly name to identify this connector.
|
</FormItem>
|
||||||
</FormDescription>
|
)}
|
||||||
<FormMessage />
|
/>
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="base_url"
|
name="base_url"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Jira Instance URL</FormLabel>
|
<FormLabel>Jira Instance URL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input placeholder="https://yourcompany.atlassian.net" {...field} />
|
||||||
placeholder="https://yourcompany.atlassian.net"
|
</FormControl>
|
||||||
{...field}
|
<FormDescription>
|
||||||
/>
|
Your Jira instance URL. For Atlassian Cloud, this is typically
|
||||||
</FormControl>
|
https://yourcompany.atlassian.net
|
||||||
<FormDescription>
|
</FormDescription>
|
||||||
Your Jira instance URL. For Atlassian Cloud, this is
|
<FormMessage />
|
||||||
typically https://yourcompany.atlassian.net
|
</FormItem>
|
||||||
</FormDescription>
|
)}
|
||||||
<FormMessage />
|
/>
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email Address</FormLabel>
|
<FormLabel>Email Address</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input type="email" placeholder="your.email@company.com" {...field} />
|
||||||
type="email"
|
</FormControl>
|
||||||
placeholder="your.email@company.com"
|
<FormDescription>Your Atlassian account email address.</FormDescription>
|
||||||
{...field}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
</FormControl>
|
)}
|
||||||
<FormDescription>
|
/>
|
||||||
Your Atlassian account email address.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="api_token"
|
name="api_token"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>API Token</FormLabel>
|
<FormLabel>API Token</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input type="password" placeholder="Your Jira API Token" {...field} />
|
||||||
type="password"
|
</FormControl>
|
||||||
placeholder="Your Jira API Token"
|
<FormDescription>
|
||||||
{...field}
|
Your Jira API Token will be encrypted and stored securely.
|
||||||
/>
|
</FormDescription>
|
||||||
</FormControl>
|
<FormMessage />
|
||||||
<FormDescription>
|
</FormItem>
|
||||||
Your Jira API Token will be encrypted and stored securely.
|
)}
|
||||||
</FormDescription>
|
/>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||||
type="submit"
|
{isSubmitting ? (
|
||||||
disabled={isSubmitting}
|
<>
|
||||||
className="w-full sm:w-auto"
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
>
|
Connecting...
|
||||||
{isSubmitting ? (
|
</>
|
||||||
<>
|
) : (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<>
|
||||||
Connecting...
|
<Check className="mr-2 h-4 w-4" />
|
||||||
</>
|
Connect Jira
|
||||||
) : (
|
</>
|
||||||
<>
|
)}
|
||||||
<Check className="mr-2 h-4 w-4" />
|
</Button>
|
||||||
Connect Jira
|
</div>
|
||||||
</>
|
</form>
|
||||||
)}
|
</Form>
|
||||||
</Button>
|
</CardContent>
|
||||||
</div>
|
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||||
</form>
|
<h4 className="text-sm font-medium">What you get with Jira integration:</h4>
|
||||||
</Form>
|
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||||
</CardContent>
|
<li>Search through all your Jira issues and tickets</li>
|
||||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
<li>Access issue descriptions, comments, and full discussion threads</li>
|
||||||
<h4 className="text-sm font-medium">
|
<li>Connect your team's project management directly to your search space</li>
|
||||||
What you get with Jira integration:
|
<li>Keep your search results up-to-date with latest Jira content</li>
|
||||||
</h4>
|
<li>Index your Jira issues for enhanced search capabilities</li>
|
||||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
<li>Search by issue keys, status, priority, and assignee information</li>
|
||||||
<li>Search through all your Jira issues and tickets</li>
|
</ul>
|
||||||
<li>
|
</CardFooter>
|
||||||
Access issue descriptions, comments, and full discussion
|
</Card>
|
||||||
threads
|
</TabsContent>
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Connect your team's project management directly to your
|
|
||||||
search space
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Keep your search results up-to-date with latest Jira content
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Index your Jira issues for enhanced search capabilities
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Search by issue keys, status, priority, and assignee
|
|
||||||
information
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="documentation">
|
<TabsContent value="documentation">
|
||||||
<Card className="border-2 border-border">
|
<Card className="border-2 border-border">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl font-bold">
|
<CardTitle className="text-2xl font-bold">Jira Connector Documentation</CardTitle>
|
||||||
Jira Connector Documentation
|
<CardDescription>
|
||||||
</CardTitle>
|
Learn how to set up and use the Jira connector to index your project management
|
||||||
<CardDescription>
|
data.
|
||||||
Learn how to set up and use the Jira connector to index your
|
</CardDescription>
|
||||||
project management data.
|
</CardHeader>
|
||||||
</CardDescription>
|
<CardContent className="space-y-6">
|
||||||
</CardHeader>
|
<div>
|
||||||
<CardContent className="space-y-6">
|
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||||
<div>
|
<p className="text-muted-foreground">
|
||||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
The Jira connector uses the Jira REST API with Basic Authentication to fetch all
|
||||||
<p className="text-muted-foreground">
|
issues and comments that your account has access to within your Jira instance.
|
||||||
The Jira connector uses the Jira REST API with Basic Authentication
|
</p>
|
||||||
to fetch all issues and comments that your account has
|
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||||
access to within your Jira instance.
|
<li>
|
||||||
</p>
|
For follow up indexing runs, the connector retrieves issues and comments that
|
||||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
have been updated since the last indexing attempt.
|
||||||
<li>
|
</li>
|
||||||
For follow up indexing runs, the connector retrieves
|
<li>
|
||||||
issues and comments that have been updated since the last
|
Indexing is configured to run periodically, so updates should appear in your
|
||||||
indexing attempt.
|
search results within minutes.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
</ul>
|
||||||
Indexing is configured to run periodically, so updates
|
</div>
|
||||||
should appear in your search results within minutes.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Accordion type="single" collapsible className="w-full">
|
<Accordion type="single" collapsible className="w-full">
|
||||||
<AccordionItem value="authorization">
|
<AccordionItem value="authorization">
|
||||||
<AccordionTrigger className="text-lg font-medium">
|
<AccordionTrigger className="text-lg font-medium">
|
||||||
Authorization
|
Authorization
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="space-y-4">
|
<AccordionContent className="space-y-4">
|
||||||
<Alert className="bg-muted">
|
<Alert className="bg-muted">
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
<AlertTitle>Read-Only Access is Sufficient</AlertTitle>
|
<AlertTitle>Read-Only Access is Sufficient</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
You only need read access for this connector to work.
|
You only need read access for this connector to work. The API Token will
|
||||||
The API Token will only be used to read your Jira data.
|
only be used to read your Jira data.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium mb-2">
|
<h4 className="font-medium mb-2">Step 1: Create an API Token</h4>
|
||||||
Step 1: Create an API Token
|
<ol className="list-decimal pl-5 space-y-3">
|
||||||
</h4>
|
<li>Log in to your Atlassian account</li>
|
||||||
<ol className="list-decimal pl-5 space-y-3">
|
<li>
|
||||||
<li>Log in to your Atlassian account</li>
|
Navigate to{" "}
|
||||||
<li>
|
<a
|
||||||
Navigate to{" "}
|
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
||||||
<a
|
target="_blank"
|
||||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
rel="noopener noreferrer"
|
||||||
target="_blank"
|
className="font-medium underline underline-offset-4"
|
||||||
rel="noopener noreferrer"
|
>
|
||||||
className="font-medium underline underline-offset-4"
|
https://id.atlassian.com/manage-profile/security/api-tokens
|
||||||
>
|
</a>
|
||||||
https://id.atlassian.com/manage-profile/security/api-tokens
|
</li>
|
||||||
</a>
|
<li>
|
||||||
</li>
|
Click <strong>Create API token</strong>
|
||||||
<li>
|
</li>
|
||||||
Click <strong>Create API token</strong>
|
<li>Enter a label for your token (like "SurfSense Connector")</li>
|
||||||
</li>
|
<li>
|
||||||
<li>
|
Click <strong>Create</strong>
|
||||||
Enter a label for your token (like "SurfSense
|
</li>
|
||||||
Connector")
|
<li>Copy the generated token as it will only be shown once</li>
|
||||||
</li>
|
</ol>
|
||||||
<li>
|
</div>
|
||||||
Click <strong>Create</strong>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Copy the generated token as it will only be shown
|
|
||||||
once
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium mb-2">
|
<h4 className="font-medium mb-2">Step 2: Grant necessary access</h4>
|
||||||
Step 2: Grant necessary access
|
<p className="text-muted-foreground mb-3">
|
||||||
</h4>
|
The API Token will have access to all projects and issues that your user
|
||||||
<p className="text-muted-foreground mb-3">
|
account can see. Make sure your account has appropriate permissions for
|
||||||
The API Token will have access to all projects and
|
the projects you want to index.
|
||||||
issues that your user account can see. Make sure your
|
</p>
|
||||||
account has appropriate permissions for the projects
|
<Alert className="bg-muted">
|
||||||
you want to index.
|
<Info className="h-4 w-4" />
|
||||||
</p>
|
<AlertTitle>Data Privacy</AlertTitle>
|
||||||
<Alert className="bg-muted">
|
<AlertDescription>
|
||||||
<Info className="h-4 w-4" />
|
Only issues, comments, and basic metadata will be indexed. Jira
|
||||||
<AlertTitle>Data Privacy</AlertTitle>
|
attachments and linked files are not indexed by this connector.
|
||||||
<AlertDescription>
|
</AlertDescription>
|
||||||
Only issues, comments, and basic metadata will be
|
</Alert>
|
||||||
indexed. Jira attachments and linked files are not
|
</div>
|
||||||
indexed by this connector.
|
</div>
|
||||||
</AlertDescription>
|
</AccordionContent>
|
||||||
</Alert>
|
</AccordionItem>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem value="indexing">
|
<AccordionItem value="indexing">
|
||||||
<AccordionTrigger className="text-lg font-medium">
|
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||||
Indexing
|
<AccordionContent className="space-y-4">
|
||||||
</AccordionTrigger>
|
<ol className="list-decimal pl-5 space-y-3">
|
||||||
<AccordionContent className="space-y-4">
|
<li>
|
||||||
<ol className="list-decimal pl-5 space-y-3">
|
Navigate to the Connector Dashboard and select the <strong>Jira</strong>{" "}
|
||||||
<li>
|
Connector.
|
||||||
Navigate to the Connector Dashboard and select the{" "}
|
</li>
|
||||||
<strong>Jira</strong> Connector.
|
<li>
|
||||||
</li>
|
Enter your <strong>Jira Instance URL</strong> (e.g.,
|
||||||
<li>
|
https://yourcompany.atlassian.net)
|
||||||
Enter your <strong>Jira Instance URL</strong> (e.g.,
|
</li>
|
||||||
https://yourcompany.atlassian.net)
|
<li>
|
||||||
</li>
|
Place your <strong>Personal Access Token</strong> in the form field.
|
||||||
<li>
|
</li>
|
||||||
Place your <strong>Personal Access Token</strong> in
|
<li>
|
||||||
the form field.
|
Click <strong>Connect</strong> to establish the connection.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>Once connected, your Jira issues will be indexed automatically.</li>
|
||||||
Click <strong>Connect</strong> to establish the
|
</ol>
|
||||||
connection.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Once connected, your Jira issues will be indexed
|
|
||||||
automatically.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<Alert className="bg-muted">
|
<Alert className="bg-muted">
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
<AlertTitle>What Gets Indexed</AlertTitle>
|
<AlertTitle>What Gets Indexed</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<p className="mb-2">
|
<p className="mb-2">The Jira connector indexes the following data:</p>
|
||||||
The Jira connector indexes the following data:
|
<ul className="list-disc pl-5">
|
||||||
</p>
|
<li>Issue keys and summaries (e.g., PROJ-123)</li>
|
||||||
<ul className="list-disc pl-5">
|
<li>Issue descriptions</li>
|
||||||
<li>Issue keys and summaries (e.g., PROJ-123)</li>
|
<li>Issue comments and discussion threads</li>
|
||||||
<li>Issue descriptions</li>
|
<li>Issue status, priority, and type information</li>
|
||||||
<li>Issue comments and discussion threads</li>
|
<li>Assignee and reporter information</li>
|
||||||
<li>
|
<li>Project information</li>
|
||||||
Issue status, priority, and type information
|
</ul>
|
||||||
</li>
|
</AlertDescription>
|
||||||
<li>Assignee and reporter information</li>
|
</Alert>
|
||||||
<li>Project information</li>
|
</AccordionContent>
|
||||||
</ul>
|
</AccordionItem>
|
||||||
</AlertDescription>
|
</Accordion>
|
||||||
</Alert>
|
</CardContent>
|
||||||
</AccordionContent>
|
</Card>
|
||||||
</AccordionItem>
|
</TabsContent>
|
||||||
</Accordion>
|
</Tabs>
|
||||||
</CardContent>
|
</motion.div>
|
||||||
</Card>
|
</div>
|
||||||
</TabsContent>
|
);
|
||||||
</Tabs>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,321 +1,353 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter, useParams } from "next/navigation";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { motion } from "framer-motion";
|
||||||
import * as z from "zod";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import * as z from "zod";
|
||||||
import {
|
import {
|
||||||
Form,
|
Accordion,
|
||||||
FormControl,
|
AccordionContent,
|
||||||
FormDescription,
|
AccordionItem,
|
||||||
FormField,
|
AccordionTrigger,
|
||||||
FormItem,
|
} from "@/components/ui/accordion";
|
||||||
FormLabel,
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Form,
|
||||||
AlertDescription,
|
FormControl,
|
||||||
AlertTitle,
|
FormDescription,
|
||||||
} from "@/components/ui/alert";
|
FormField,
|
||||||
import {
|
FormItem,
|
||||||
Accordion,
|
FormLabel,
|
||||||
AccordionContent,
|
FormMessage,
|
||||||
AccordionItem,
|
} from "@/components/ui/form";
|
||||||
AccordionTrigger,
|
import { Input } from "@/components/ui/input";
|
||||||
} from "@/components/ui/accordion";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||||
|
|
||||||
// Define the form schema with Zod
|
// Define the form schema with Zod
|
||||||
const linearConnectorFormSchema = z.object({
|
const linearConnectorFormSchema = z.object({
|
||||||
name: z.string().min(3, {
|
name: z.string().min(3, {
|
||||||
message: "Connector name must be at least 3 characters.",
|
message: "Connector name must be at least 3 characters.",
|
||||||
}),
|
}),
|
||||||
api_key: z.string().min(10, {
|
api_key: z
|
||||||
message: "Linear API Key is required and must be valid.",
|
.string()
|
||||||
}).regex(/^lin_api_/, {
|
.min(10, {
|
||||||
message: "Linear API Key should start with 'lin_api_'",
|
message: "Linear API Key is required and must be valid.",
|
||||||
}),
|
})
|
||||||
|
.regex(/^lin_api_/, {
|
||||||
|
message: "Linear API Key should start with 'lin_api_'",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Define the type for the form values
|
// Define the type for the form values
|
||||||
type LinearConnectorFormValues = z.infer<typeof linearConnectorFormSchema>;
|
type LinearConnectorFormValues = z.infer<typeof linearConnectorFormSchema>;
|
||||||
|
|
||||||
export default function LinearConnectorPage() {
|
export default function LinearConnectorPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchSpaceId = params.search_space_id as string;
|
const searchSpaceId = params.search_space_id as string;
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { createConnector } = useSearchSourceConnectors();
|
const { createConnector } = useSearchSourceConnectors();
|
||||||
|
|
||||||
// Initialize the form
|
// Initialize the form
|
||||||
const form = useForm<LinearConnectorFormValues>({
|
const form = useForm<LinearConnectorFormValues>({
|
||||||
resolver: zodResolver(linearConnectorFormSchema),
|
resolver: zodResolver(linearConnectorFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "Linear Connector",
|
name: "Linear Connector",
|
||||||
api_key: "",
|
api_key: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
const onSubmit = async (values: LinearConnectorFormValues) => {
|
const onSubmit = async (values: LinearConnectorFormValues) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await createConnector({
|
await createConnector({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
connector_type: "LINEAR_CONNECTOR",
|
connector_type: "LINEAR_CONNECTOR",
|
||||||
config: {
|
config: {
|
||||||
LINEAR_API_KEY: values.api_key,
|
LINEAR_API_KEY: values.api_key,
|
||||||
},
|
},
|
||||||
is_indexable: true,
|
is_indexable: true,
|
||||||
last_indexed_at: null,
|
last_indexed_at: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Linear connector created successfully!");
|
toast.success("Linear connector created successfully!");
|
||||||
|
|
||||||
// Navigate back to connectors page
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating connector:", error);
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
// Navigate back to connectors page
|
||||||
<div className="container mx-auto py-8 max-w-3xl">
|
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||||
<Button
|
} catch (error) {
|
||||||
variant="ghost"
|
console.error("Error creating connector:", error);
|
||||||
className="mb-6"
|
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
} finally {
|
||||||
>
|
setIsSubmitting(false);
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
}
|
||||||
Back to Connectors
|
};
|
||||||
</Button>
|
|
||||||
|
|
||||||
<motion.div
|
return (
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<div className="container mx-auto py-8 max-w-3xl">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<Button
|
||||||
transition={{ duration: 0.5 }}
|
variant="ghost"
|
||||||
>
|
className="mb-6"
|
||||||
<Tabs defaultValue="connect" className="w-full">
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
>
|
||||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
Back to Connectors
|
||||||
</TabsList>
|
</Button>
|
||||||
|
|
||||||
<TabsContent value="connect">
|
|
||||||
<Card className="border-2 border-border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-2xl font-bold">Connect Linear Workspace</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Integrate with Linear to search and retrieve information from your issues and comments. This connector can index your Linear content for search.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Alert className="mb-6 bg-muted">
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<AlertTitle>Linear API Key Required</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
You'll need a Linear API Key to use this connector. You can create a Linear API key from{" "}
|
|
||||||
<a
|
|
||||||
href="https://linear.app/settings/api"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-medium underline underline-offset-4"
|
|
||||||
>
|
|
||||||
Linear API Settings
|
|
||||||
</a>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Form {...form}>
|
<motion.div
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<FormField
|
animate={{ opacity: 1, y: 0 }}
|
||||||
control={form.control}
|
transition={{ duration: 0.5 }}
|
||||||
name="name"
|
>
|
||||||
render={({ field }) => (
|
<Tabs defaultValue="connect" className="w-full">
|
||||||
<FormItem>
|
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||||
<FormLabel>Connector Name</FormLabel>
|
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||||
<FormControl>
|
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||||
<Input placeholder="My Linear Connector" {...field} />
|
</TabsList>
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
A friendly name to identify this connector.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<TabsContent value="connect">
|
||||||
control={form.control}
|
<Card className="border-2 border-border">
|
||||||
name="api_key"
|
<CardHeader>
|
||||||
render={({ field }) => (
|
<CardTitle className="text-2xl font-bold">Connect Linear Workspace</CardTitle>
|
||||||
<FormItem>
|
<CardDescription>
|
||||||
<FormLabel>Linear API Key</FormLabel>
|
Integrate with Linear to search and retrieve information from your issues and
|
||||||
<FormControl>
|
comments. This connector can index your Linear content for search.
|
||||||
<Input
|
</CardDescription>
|
||||||
type="password"
|
</CardHeader>
|
||||||
placeholder="lin_api_..."
|
<CardContent>
|
||||||
{...field}
|
<Alert className="mb-6 bg-muted">
|
||||||
/>
|
<Info className="h-4 w-4" />
|
||||||
</FormControl>
|
<AlertTitle>Linear API Key Required</AlertTitle>
|
||||||
<FormDescription>
|
<AlertDescription>
|
||||||
Your Linear API Key will be encrypted and stored securely. It typically starts with "lin_api_".
|
You'll need a Linear API Key to use this connector. You can create a Linear API
|
||||||
</FormDescription>
|
key from{" "}
|
||||||
<FormMessage />
|
<a
|
||||||
</FormItem>
|
href="https://linear.app/settings/api"
|
||||||
)}
|
target="_blank"
|
||||||
/>
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Linear API Settings
|
||||||
|
</a>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<Form {...form}>
|
||||||
<Button
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
type="submit"
|
<FormField
|
||||||
disabled={isSubmitting}
|
control={form.control}
|
||||||
className="w-full sm:w-auto"
|
name="name"
|
||||||
>
|
render={({ field }) => (
|
||||||
{isSubmitting ? (
|
<FormItem>
|
||||||
<>
|
<FormLabel>Connector Name</FormLabel>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<FormControl>
|
||||||
Connecting...
|
<Input placeholder="My Linear Connector" {...field} />
|
||||||
</>
|
</FormControl>
|
||||||
) : (
|
<FormDescription>
|
||||||
<>
|
A friendly name to identify this connector.
|
||||||
<Check className="mr-2 h-4 w-4" />
|
</FormDescription>
|
||||||
Connect Linear
|
<FormMessage />
|
||||||
</>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
|
||||||
<h4 className="text-sm font-medium">What you get with Linear integration:</h4>
|
|
||||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
|
||||||
<li>Search through all your Linear issues and comments</li>
|
|
||||||
<li>Access issue titles, descriptions, and full discussion threads</li>
|
|
||||||
<li>Connect your team's project management directly to your search space</li>
|
|
||||||
<li>Keep your search results up-to-date with latest Linear content</li>
|
|
||||||
<li>Index your Linear issues for enhanced search capabilities</li>
|
|
||||||
</ul>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="documentation">
|
|
||||||
<Card className="border-2 border-border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-2xl font-bold">Linear Connector Documentation</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Learn how to set up and use the Linear connector to index your project management data.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
The Linear connector uses the Linear GraphQL API to fetch all issues and comments that the API key has access to within a workspace.
|
|
||||||
</p>
|
|
||||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
|
||||||
<li>For follow up indexing runs, the connector retrieves issues and comments that have been updated since the last indexing attempt.</li>
|
|
||||||
<li>Indexing is configured to run periodically, so updates should appear in your search results within minutes.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Accordion type="single" collapsible className="w-full">
|
|
||||||
<AccordionItem value="authorization">
|
|
||||||
<AccordionTrigger className="text-lg font-medium">Authorization</AccordionTrigger>
|
|
||||||
<AccordionContent className="space-y-4">
|
|
||||||
<Alert className="bg-muted">
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<AlertTitle>Read-Only Access is Sufficient</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
You only need a read-only API key for this connector to work. This limits the permissions to just reading your Linear data.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium mb-2">Step 1: Create an API key</h4>
|
|
||||||
<ol className="list-decimal pl-5 space-y-3">
|
|
||||||
<li>Log in to your Linear account</li>
|
|
||||||
<li>Navigate to <a href="https://linear.app/settings/api" target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-4">https://linear.app/settings/api</a> in your browser.</li>
|
|
||||||
<li>Alternatively, click on your profile picture → Settings → API</li>
|
|
||||||
<li>Click the <strong>+ New API key</strong> button.</li>
|
|
||||||
<li>Enter a description for your key (like "Search Connector").</li>
|
|
||||||
<li>Select "Read-only" as the permission.</li>
|
|
||||||
<li>Click <strong>Create</strong> to generate the API key.</li>
|
|
||||||
<li>Copy the generated API key that starts with 'lin_api_' as it will only be shown once.</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<FormField
|
||||||
<h4 className="font-medium mb-2">Step 2: Grant necessary access</h4>
|
control={form.control}
|
||||||
<p className="text-muted-foreground mb-3">
|
name="api_key"
|
||||||
The API key will have access to all issues and comments that your user account can see. If you're creating the key as an admin, it will have access to all issues in the workspace.
|
render={({ field }) => (
|
||||||
</p>
|
<FormItem>
|
||||||
<Alert className="bg-muted">
|
<FormLabel>Linear API Key</FormLabel>
|
||||||
<Info className="h-4 w-4" />
|
<FormControl>
|
||||||
<AlertTitle>Data Privacy</AlertTitle>
|
<Input type="password" placeholder="lin_api_..." {...field} />
|
||||||
<AlertDescription>
|
</FormControl>
|
||||||
Only issues and comments will be indexed. Linear attachments and linked files are not indexed by this connector.
|
<FormDescription>
|
||||||
</AlertDescription>
|
Your Linear API Key will be encrypted and stored securely. It typically
|
||||||
</Alert>
|
starts with "lin_api_".
|
||||||
</div>
|
</FormDescription>
|
||||||
</div>
|
<FormMessage />
|
||||||
</AccordionContent>
|
</FormItem>
|
||||||
</AccordionItem>
|
)}
|
||||||
|
/>
|
||||||
<AccordionItem value="indexing">
|
|
||||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
<div className="flex justify-end">
|
||||||
<AccordionContent className="space-y-4">
|
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||||
<ol className="list-decimal pl-5 space-y-3">
|
{isSubmitting ? (
|
||||||
<li>Navigate to the Connector Dashboard and select the <strong>Linear</strong> Connector.</li>
|
<>
|
||||||
<li>Place the <strong>API Key</strong> in the form field.</li>
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<li>Click <strong>Connect</strong> to establish the connection.</li>
|
Connecting...
|
||||||
<li>Once connected, your Linear issues will be indexed automatically.</li>
|
</>
|
||||||
</ol>
|
) : (
|
||||||
|
<>
|
||||||
<Alert className="bg-muted">
|
<Check className="mr-2 h-4 w-4" />
|
||||||
<Info className="h-4 w-4" />
|
Connect Linear
|
||||||
<AlertTitle>What Gets Indexed</AlertTitle>
|
</>
|
||||||
<AlertDescription>
|
)}
|
||||||
<p className="mb-2">The Linear connector indexes the following data:</p>
|
</Button>
|
||||||
<ul className="list-disc pl-5">
|
</div>
|
||||||
<li>Issue titles and identifiers (e.g., PROJ-123)</li>
|
</form>
|
||||||
<li>Issue descriptions</li>
|
</Form>
|
||||||
<li>Issue comments</li>
|
</CardContent>
|
||||||
<li>Issue status and metadata</li>
|
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||||
</ul>
|
<h4 className="text-sm font-medium">What you get with Linear integration:</h4>
|
||||||
</AlertDescription>
|
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||||
</Alert>
|
<li>Search through all your Linear issues and comments</li>
|
||||||
</AccordionContent>
|
<li>Access issue titles, descriptions, and full discussion threads</li>
|
||||||
</AccordionItem>
|
<li>Connect your team's project management directly to your search space</li>
|
||||||
</Accordion>
|
<li>Keep your search results up-to-date with latest Linear content</li>
|
||||||
</CardContent>
|
<li>Index your Linear issues for enhanced search capabilities</li>
|
||||||
</Card>
|
</ul>
|
||||||
</TabsContent>
|
</CardFooter>
|
||||||
</Tabs>
|
</Card>
|
||||||
</motion.div>
|
</TabsContent>
|
||||||
</div>
|
|
||||||
);
|
<TabsContent value="documentation">
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl font-bold">Linear Connector Documentation</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Learn how to set up and use the Linear connector to index your project management
|
||||||
|
data.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
The Linear connector uses the Linear GraphQL API to fetch all issues and
|
||||||
|
comments that the API key has access to within a workspace.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||||
|
<li>
|
||||||
|
For follow up indexing runs, the connector retrieves issues and comments that
|
||||||
|
have been updated since the last indexing attempt.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Indexing is configured to run periodically, so updates should appear in your
|
||||||
|
search results within minutes.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
<AccordionItem value="authorization">
|
||||||
|
<AccordionTrigger className="text-lg font-medium">
|
||||||
|
Authorization
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4">
|
||||||
|
<Alert className="bg-muted">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>Read-Only Access is Sufficient</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
You only need a read-only API key for this connector to work. This limits
|
||||||
|
the permissions to just reading your Linear data.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">Step 1: Create an API key</h4>
|
||||||
|
<ol className="list-decimal pl-5 space-y-3">
|
||||||
|
<li>Log in to your Linear account</li>
|
||||||
|
<li>
|
||||||
|
Navigate to{" "}
|
||||||
|
<a
|
||||||
|
href="https://linear.app/settings/api"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
https://linear.app/settings/api
|
||||||
|
</a>{" "}
|
||||||
|
in your browser.
|
||||||
|
</li>
|
||||||
|
<li>Alternatively, click on your profile picture → Settings → API</li>
|
||||||
|
<li>
|
||||||
|
Click the <strong>+ New API key</strong> button.
|
||||||
|
</li>
|
||||||
|
<li>Enter a description for your key (like "Search Connector").</li>
|
||||||
|
<li>Select "Read-only" as the permission.</li>
|
||||||
|
<li>
|
||||||
|
Click <strong>Create</strong> to generate the API key.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Copy the generated API key that starts with 'lin_api_' as it will only
|
||||||
|
be shown once.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">Step 2: Grant necessary access</h4>
|
||||||
|
<p className="text-muted-foreground mb-3">
|
||||||
|
The API key will have access to all issues and comments that your user
|
||||||
|
account can see. If you're creating the key as an admin, it will have
|
||||||
|
access to all issues in the workspace.
|
||||||
|
</p>
|
||||||
|
<Alert className="bg-muted">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>Data Privacy</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Only issues and comments will be indexed. Linear attachments and
|
||||||
|
linked files are not indexed by this connector.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="indexing">
|
||||||
|
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4">
|
||||||
|
<ol className="list-decimal pl-5 space-y-3">
|
||||||
|
<li>
|
||||||
|
Navigate to the Connector Dashboard and select the <strong>Linear</strong>{" "}
|
||||||
|
Connector.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Place the <strong>API Key</strong> in the form field.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Click <strong>Connect</strong> to establish the connection.
|
||||||
|
</li>
|
||||||
|
<li>Once connected, your Linear issues will be indexed automatically.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<Alert className="bg-muted">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>What Gets Indexed</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p className="mb-2">The Linear connector indexes the following data:</p>
|
||||||
|
<ul className="list-disc pl-5">
|
||||||
|
<li>Issue titles and identifiers (e.g., PROJ-123)</li>
|
||||||
|
<li>Issue descriptions</li>
|
||||||
|
<li>Issue comments</li>
|
||||||
|
<li>Issue status and metadata</li>
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,207 +1,193 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter, useParams } from "next/navigation";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { motion } from "framer-motion";
|
||||||
import * as z from "zod";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
import { useState } from "react";
|
||||||
import {
|
import { useForm } from "react-hook-form";
|
||||||
Form,
|
import { toast } from "sonner";
|
||||||
FormControl,
|
import * as z from "zod";
|
||||||
FormDescription,
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Form,
|
||||||
AlertDescription,
|
FormControl,
|
||||||
AlertTitle,
|
FormDescription,
|
||||||
} from "@/components/ui/alert";
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||||
|
|
||||||
// Define the form schema with Zod
|
// Define the form schema with Zod
|
||||||
const linkupApiFormSchema = z.object({
|
const linkupApiFormSchema = z.object({
|
||||||
name: z.string().min(3, {
|
name: z.string().min(3, {
|
||||||
message: "Connector name must be at least 3 characters.",
|
message: "Connector name must be at least 3 characters.",
|
||||||
}),
|
}),
|
||||||
api_key: z.string().min(10, {
|
api_key: z.string().min(10, {
|
||||||
message: "API key is required and must be valid.",
|
message: "API key is required and must be valid.",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Define the type for the form values
|
// Define the type for the form values
|
||||||
type LinkupApiFormValues = z.infer<typeof linkupApiFormSchema>;
|
type LinkupApiFormValues = z.infer<typeof linkupApiFormSchema>;
|
||||||
|
|
||||||
export default function LinkupApiPage() {
|
export default function LinkupApiPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchSpaceId = params.search_space_id as string;
|
const searchSpaceId = params.search_space_id as string;
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { createConnector } = useSearchSourceConnectors();
|
const { createConnector } = useSearchSourceConnectors();
|
||||||
|
|
||||||
// Initialize the form
|
// Initialize the form
|
||||||
const form = useForm<LinkupApiFormValues>({
|
const form = useForm<LinkupApiFormValues>({
|
||||||
resolver: zodResolver(linkupApiFormSchema),
|
resolver: zodResolver(linkupApiFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "Linkup API Connector",
|
name: "Linkup API Connector",
|
||||||
api_key: "",
|
api_key: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
const onSubmit = async (values: LinkupApiFormValues) => {
|
const onSubmit = async (values: LinkupApiFormValues) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await createConnector({
|
await createConnector({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
connector_type: "LINKUP_API",
|
connector_type: "LINKUP_API",
|
||||||
config: {
|
config: {
|
||||||
LINKUP_API_KEY: values.api_key,
|
LINKUP_API_KEY: values.api_key,
|
||||||
},
|
},
|
||||||
is_indexable: false,
|
is_indexable: false,
|
||||||
last_indexed_at: null,
|
last_indexed_at: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Linkup API connector created successfully!");
|
toast.success("Linkup API connector created successfully!");
|
||||||
|
|
||||||
// Navigate back to connectors page
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating connector:", error);
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
// Navigate back to connectors page
|
||||||
<div className="container mx-auto py-8 max-w-3xl">
|
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||||
<Button
|
} catch (error) {
|
||||||
variant="ghost"
|
console.error("Error creating connector:", error);
|
||||||
className="mb-6"
|
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
} finally {
|
||||||
>
|
setIsSubmitting(false);
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
}
|
||||||
Back to Connectors
|
};
|
||||||
</Button>
|
|
||||||
|
|
||||||
<motion.div
|
return (
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<div className="container mx-auto py-8 max-w-3xl">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<Button
|
||||||
transition={{ duration: 0.5 }}
|
variant="ghost"
|
||||||
>
|
className="mb-6"
|
||||||
<Card className="border-2 border-border">
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||||
<CardHeader>
|
>
|
||||||
<CardTitle className="text-2xl font-bold">Connect Linkup API</CardTitle>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<CardDescription>
|
Back to Connectors
|
||||||
Integrate with Linkup API to enhance your search capabilities with AI-powered search results.
|
</Button>
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Alert className="mb-6 bg-muted">
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<AlertTitle>API Key Required</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
You'll need a Linkup API key to use this connector. You can get one by signing up at{" "}
|
|
||||||
<a
|
|
||||||
href="https://linkup.so"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-medium underline underline-offset-4"
|
|
||||||
>
|
|
||||||
linkup.so
|
|
||||||
</a>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Form {...form}>
|
<motion.div
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<FormField
|
animate={{ opacity: 1, y: 0 }}
|
||||||
control={form.control}
|
transition={{ duration: 0.5 }}
|
||||||
name="name"
|
>
|
||||||
render={({ field }) => (
|
<Card className="border-2 border-border">
|
||||||
<FormItem>
|
<CardHeader>
|
||||||
<FormLabel>Connector Name</FormLabel>
|
<CardTitle className="text-2xl font-bold">Connect Linkup API</CardTitle>
|
||||||
<FormControl>
|
<CardDescription>
|
||||||
<Input placeholder="My Linkup API Connector" {...field} />
|
Integrate with Linkup API to enhance your search capabilities with AI-powered search
|
||||||
</FormControl>
|
results.
|
||||||
<FormDescription>
|
</CardDescription>
|
||||||
A friendly name to identify this connector.
|
</CardHeader>
|
||||||
</FormDescription>
|
<CardContent>
|
||||||
<FormMessage />
|
<Alert className="mb-6 bg-muted">
|
||||||
</FormItem>
|
<Info className="h-4 w-4" />
|
||||||
)}
|
<AlertTitle>API Key Required</AlertTitle>
|
||||||
/>
|
<AlertDescription>
|
||||||
|
You'll need a Linkup API key to use this connector. You can get one by signing up at{" "}
|
||||||
|
<a
|
||||||
|
href="https://linkup.so"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
linkup.so
|
||||||
|
</a>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<FormField
|
<Form {...form}>
|
||||||
control={form.control}
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
name="api_key"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="name"
|
||||||
<FormLabel>Linkup API Key</FormLabel>
|
render={({ field }) => (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Input
|
<FormLabel>Connector Name</FormLabel>
|
||||||
type="password"
|
<FormControl>
|
||||||
placeholder="Enter your Linkup API key"
|
<Input placeholder="My Linkup API Connector" {...field} />
|
||||||
{...field}
|
</FormControl>
|
||||||
/>
|
<FormDescription>A friendly name to identify this connector.</FormDescription>
|
||||||
</FormControl>
|
<FormMessage />
|
||||||
<FormDescription>
|
</FormItem>
|
||||||
Your API key will be encrypted and stored securely.
|
)}
|
||||||
</FormDescription>
|
/>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<FormField
|
||||||
<Button
|
control={form.control}
|
||||||
type="submit"
|
name="api_key"
|
||||||
disabled={isSubmitting}
|
render={({ field }) => (
|
||||||
className="w-full sm:w-auto"
|
<FormItem>
|
||||||
>
|
<FormLabel>Linkup API Key</FormLabel>
|
||||||
{isSubmitting ? (
|
<FormControl>
|
||||||
<>
|
<Input type="password" placeholder="Enter your Linkup API key" {...field} />
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
</FormControl>
|
||||||
Connecting...
|
<FormDescription>
|
||||||
</>
|
Your API key will be encrypted and stored securely.
|
||||||
) : (
|
</FormDescription>
|
||||||
<>
|
<FormMessage />
|
||||||
<Check className="mr-2 h-4 w-4" />
|
</FormItem>
|
||||||
Connect Linkup API
|
)}
|
||||||
</>
|
/>
|
||||||
)}
|
|
||||||
</Button>
|
<div className="flex justify-end">
|
||||||
</div>
|
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||||
</form>
|
{isSubmitting ? (
|
||||||
</Form>
|
<>
|
||||||
</CardContent>
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
Connecting...
|
||||||
<h4 className="text-sm font-medium">What you get with Linkup API:</h4>
|
</>
|
||||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
) : (
|
||||||
<li>AI-powered search results tailored to your queries</li>
|
<>
|
||||||
<li>Real-time information from the web</li>
|
<Check className="mr-2 h-4 w-4" />
|
||||||
<li>Enhanced search capabilities for your projects</li>
|
Connect Linkup API
|
||||||
</ul>
|
</>
|
||||||
</CardFooter>
|
)}
|
||||||
</Card>
|
</Button>
|
||||||
</motion.div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
);
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||||
|
<h4 className="text-sm font-medium">What you get with Linkup API:</h4>
|
||||||
|
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||||
|
<li>AI-powered search results tailored to your queries</li>
|
||||||
|
<li>Real-time information from the web</li>
|
||||||
|
<li>Enhanced search capabilities for your projects</li>
|
||||||
|
</ul>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,317 +1,364 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter, useParams } from "next/navigation";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { motion } from "framer-motion";
|
||||||
import * as z from "zod";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import * as z from "zod";
|
||||||
import {
|
import {
|
||||||
Form,
|
Accordion,
|
||||||
FormControl,
|
AccordionContent,
|
||||||
FormDescription,
|
AccordionItem,
|
||||||
FormField,
|
AccordionTrigger,
|
||||||
FormItem,
|
} from "@/components/ui/accordion";
|
||||||
FormLabel,
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Form,
|
||||||
AlertDescription,
|
FormControl,
|
||||||
AlertTitle,
|
FormDescription,
|
||||||
} from "@/components/ui/alert";
|
FormField,
|
||||||
import {
|
FormItem,
|
||||||
Accordion,
|
FormLabel,
|
||||||
AccordionContent,
|
FormMessage,
|
||||||
AccordionItem,
|
} from "@/components/ui/form";
|
||||||
AccordionTrigger,
|
import { Input } from "@/components/ui/input";
|
||||||
} from "@/components/ui/accordion";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||||
|
|
||||||
// Define the form schema with Zod
|
// Define the form schema with Zod
|
||||||
const notionConnectorFormSchema = z.object({
|
const notionConnectorFormSchema = z.object({
|
||||||
name: z.string().min(3, {
|
name: z.string().min(3, {
|
||||||
message: "Connector name must be at least 3 characters.",
|
message: "Connector name must be at least 3 characters.",
|
||||||
}),
|
}),
|
||||||
integration_token: z.string().min(10, {
|
integration_token: z.string().min(10, {
|
||||||
message: "Notion Integration Token is required and must be valid.",
|
message: "Notion Integration Token is required and must be valid.",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Define the type for the form values
|
// Define the type for the form values
|
||||||
type NotionConnectorFormValues = z.infer<typeof notionConnectorFormSchema>;
|
type NotionConnectorFormValues = z.infer<typeof notionConnectorFormSchema>;
|
||||||
|
|
||||||
export default function NotionConnectorPage() {
|
export default function NotionConnectorPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchSpaceId = params.search_space_id as string;
|
const searchSpaceId = params.search_space_id as string;
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { createConnector } = useSearchSourceConnectors();
|
const { createConnector } = useSearchSourceConnectors();
|
||||||
|
|
||||||
// Initialize the form
|
// Initialize the form
|
||||||
const form = useForm<NotionConnectorFormValues>({
|
const form = useForm<NotionConnectorFormValues>({
|
||||||
resolver: zodResolver(notionConnectorFormSchema),
|
resolver: zodResolver(notionConnectorFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "Notion Connector",
|
name: "Notion Connector",
|
||||||
integration_token: "",
|
integration_token: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
const onSubmit = async (values: NotionConnectorFormValues) => {
|
const onSubmit = async (values: NotionConnectorFormValues) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await createConnector({
|
await createConnector({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
connector_type: "NOTION_CONNECTOR",
|
connector_type: "NOTION_CONNECTOR",
|
||||||
config: {
|
config: {
|
||||||
NOTION_INTEGRATION_TOKEN: values.integration_token,
|
NOTION_INTEGRATION_TOKEN: values.integration_token,
|
||||||
},
|
},
|
||||||
is_indexable: true,
|
is_indexable: true,
|
||||||
last_indexed_at: null,
|
last_indexed_at: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Notion connector created successfully!");
|
toast.success("Notion connector created successfully!");
|
||||||
|
|
||||||
// Navigate back to connectors page
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating connector:", error);
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
// Navigate back to connectors page
|
||||||
<div className="container mx-auto py-8 max-w-3xl">
|
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||||
<Button
|
} catch (error) {
|
||||||
variant="ghost"
|
console.error("Error creating connector:", error);
|
||||||
className="mb-6"
|
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
} finally {
|
||||||
>
|
setIsSubmitting(false);
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
}
|
||||||
Back to Connectors
|
};
|
||||||
</Button>
|
|
||||||
|
|
||||||
<motion.div
|
return (
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<div className="container mx-auto py-8 max-w-3xl">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<Button
|
||||||
transition={{ duration: 0.5 }}
|
variant="ghost"
|
||||||
>
|
className="mb-6"
|
||||||
<Tabs defaultValue="connect" className="w-full">
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
>
|
||||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
Back to Connectors
|
||||||
</TabsList>
|
</Button>
|
||||||
|
|
||||||
<TabsContent value="connect">
|
|
||||||
<Card className="border-2 border-border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-2xl font-bold">Connect Notion Workspace</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Integrate with Notion to search and retrieve information from your workspace pages and databases. This connector can index your Notion content for search.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Alert className="mb-6 bg-muted">
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<AlertTitle>Notion Integration Token Required</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
You'll need a Notion Integration Token to use this connector. You can create a Notion integration and get the token from{" "}
|
|
||||||
<a
|
|
||||||
href="https://www.notion.so/my-integrations"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-medium underline underline-offset-4"
|
|
||||||
>
|
|
||||||
Notion Integrations Dashboard
|
|
||||||
</a>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Form {...form}>
|
<motion.div
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<FormField
|
animate={{ opacity: 1, y: 0 }}
|
||||||
control={form.control}
|
transition={{ duration: 0.5 }}
|
||||||
name="name"
|
>
|
||||||
render={({ field }) => (
|
<Tabs defaultValue="connect" className="w-full">
|
||||||
<FormItem>
|
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||||
<FormLabel>Connector Name</FormLabel>
|
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||||
<FormControl>
|
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||||
<Input placeholder="My Notion Connector" {...field} />
|
</TabsList>
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
A friendly name to identify this connector.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<TabsContent value="connect">
|
||||||
control={form.control}
|
<Card className="border-2 border-border">
|
||||||
name="integration_token"
|
<CardHeader>
|
||||||
render={({ field }) => (
|
<CardTitle className="text-2xl font-bold">Connect Notion Workspace</CardTitle>
|
||||||
<FormItem>
|
<CardDescription>
|
||||||
<FormLabel>Notion Integration Token</FormLabel>
|
Integrate with Notion to search and retrieve information from your workspace pages
|
||||||
<FormControl>
|
and databases. This connector can index your Notion content for search.
|
||||||
<Input
|
</CardDescription>
|
||||||
type="password"
|
</CardHeader>
|
||||||
placeholder="ntn_.."
|
<CardContent>
|
||||||
{...field}
|
<Alert className="mb-6 bg-muted">
|
||||||
/>
|
<Info className="h-4 w-4" />
|
||||||
</FormControl>
|
<AlertTitle>Notion Integration Token Required</AlertTitle>
|
||||||
<FormDescription>
|
<AlertDescription>
|
||||||
Your Notion Integration Token will be encrypted and stored securely. It typically starts with "ntn_".
|
You'll need a Notion Integration Token to use this connector. You can create a
|
||||||
</FormDescription>
|
Notion integration and get the token from{" "}
|
||||||
<FormMessage />
|
<a
|
||||||
</FormItem>
|
href="https://www.notion.so/my-integrations"
|
||||||
)}
|
target="_blank"
|
||||||
/>
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Notion Integrations Dashboard
|
||||||
|
</a>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<Form {...form}>
|
||||||
<Button
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
type="submit"
|
<FormField
|
||||||
disabled={isSubmitting}
|
control={form.control}
|
||||||
className="w-full sm:w-auto"
|
name="name"
|
||||||
>
|
render={({ field }) => (
|
||||||
{isSubmitting ? (
|
<FormItem>
|
||||||
<>
|
<FormLabel>Connector Name</FormLabel>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<FormControl>
|
||||||
Connecting...
|
<Input placeholder="My Notion Connector" {...field} />
|
||||||
</>
|
</FormControl>
|
||||||
) : (
|
<FormDescription>
|
||||||
<>
|
A friendly name to identify this connector.
|
||||||
<Check className="mr-2 h-4 w-4" />
|
</FormDescription>
|
||||||
Connect Notion
|
<FormMessage />
|
||||||
</>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
|
||||||
<h4 className="text-sm font-medium">What you get with Notion integration:</h4>
|
|
||||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
|
||||||
<li>Search through your Notion pages and databases</li>
|
|
||||||
<li>Access documents, wikis, and knowledge bases</li>
|
|
||||||
<li>Connect your team's knowledge directly to your search space</li>
|
|
||||||
<li>Keep your search results up-to-date with latest Notion content</li>
|
|
||||||
<li>Index your Notion documents for enhanced search capabilities</li>
|
|
||||||
</ul>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="documentation">
|
|
||||||
<Card className="border-2 border-border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-2xl font-bold">Notion Connector Documentation</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Learn how to set up and use the Notion connector to index your workspace data.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
The Notion connector uses the Notion search API to fetch all pages that the connector has access to within a workspace.
|
|
||||||
</p>
|
|
||||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
|
||||||
<li>For follow up indexing runs, the connector only retrieves pages that have been updated since the last indexing attempt.</li>
|
|
||||||
<li>Indexing is configured to run every <strong>10 minutes</strong>, so page updates should appear within 10 minutes.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Accordion type="single" collapsible className="w-full">
|
|
||||||
<AccordionItem value="authorization">
|
|
||||||
<AccordionTrigger className="text-lg font-medium">Authorization</AccordionTrigger>
|
|
||||||
<AccordionContent className="space-y-4">
|
|
||||||
<Alert className="bg-muted">
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<AlertTitle>No Admin Access Required</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
There's no requirement to be an Admin to share information with an integration. Any member can share pages and databases with it.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium mb-2">Step 1: Create an integration</h4>
|
|
||||||
<ol className="list-decimal pl-5 space-y-3">
|
|
||||||
<li>Visit <a href="https://www.notion.com/my-integrations" target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-4">https://www.notion.com/my-integrations</a> in your browser.</li>
|
|
||||||
<li>Click the <strong>+ New integration</strong> button.</li>
|
|
||||||
<li>Name the integration (something like "Search Connector" could work).</li>
|
|
||||||
<li>Select "Read content" as the only capability required.</li>
|
|
||||||
<li>Click <strong>Submit</strong> to create the integration.</li>
|
|
||||||
<li>On the next page, you'll find your Notion integration token. Make a copy of it as you'll need it to configure the connector.</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<FormField
|
||||||
<h4 className="font-medium mb-2">Step 2: Share pages/databases with your integration</h4>
|
control={form.control}
|
||||||
<p className="text-muted-foreground mb-3">
|
name="integration_token"
|
||||||
To keep your information secure, integrations don't have access to any pages or databases in the workspace at first.
|
render={({ field }) => (
|
||||||
You must share specific pages with an integration in order for the connector to access those pages.
|
<FormItem>
|
||||||
</p>
|
<FormLabel>Notion Integration Token</FormLabel>
|
||||||
<ol className="list-decimal pl-5 space-y-3">
|
<FormControl>
|
||||||
<li>Go to the page/database in your workspace.</li>
|
<Input type="password" placeholder="ntn_.." {...field} />
|
||||||
<li>Click the <code>•••</code> on the top right corner of the page.</li>
|
</FormControl>
|
||||||
<li>Scroll to the bottom of the pop-up and click <strong>Add connections</strong>.</li>
|
<FormDescription>
|
||||||
<li>Search for and select the new integration in the <code>Search for connections...</code> menu.</li>
|
Your Notion Integration Token will be encrypted and stored securely. It
|
||||||
<li>
|
typically starts with "ntn_".
|
||||||
<strong>Important:</strong>
|
</FormDescription>
|
||||||
<ul className="list-disc pl-5 mt-1">
|
<FormMessage />
|
||||||
<li>If you've added a page, all child pages also become accessible.</li>
|
</FormItem>
|
||||||
<li>If you've added a database, all rows (and their children) become accessible.</li>
|
)}
|
||||||
</ul>
|
/>
|
||||||
</li>
|
|
||||||
</ol>
|
<div className="flex justify-end">
|
||||||
</div>
|
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||||
</div>
|
{isSubmitting ? (
|
||||||
</AccordionContent>
|
<>
|
||||||
</AccordionItem>
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Connecting...
|
||||||
<AccordionItem value="indexing">
|
</>
|
||||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
) : (
|
||||||
<AccordionContent className="space-y-4">
|
<>
|
||||||
<ol className="list-decimal pl-5 space-y-3">
|
<Check className="mr-2 h-4 w-4" />
|
||||||
<li>Navigate to the Connector Dashboard and select the <strong>Notion</strong> Connector.</li>
|
Connect Notion
|
||||||
<li>Place the <strong>Integration Token</strong> under <strong>Step 1 Provide Credentials</strong>.</li>
|
</>
|
||||||
<li>Click <strong>Connect</strong> to establish the connection.</li>
|
)}
|
||||||
</ol>
|
</Button>
|
||||||
|
</div>
|
||||||
<Alert className="bg-muted">
|
</form>
|
||||||
<Info className="h-4 w-4" />
|
</Form>
|
||||||
<AlertTitle>Indexing Behavior</AlertTitle>
|
</CardContent>
|
||||||
<AlertDescription>
|
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||||
The Notion connector currently indexes everything it has access to. If you want to limit specific content being indexed, simply unshare the database from Notion with the integration.
|
<h4 className="text-sm font-medium">What you get with Notion integration:</h4>
|
||||||
</AlertDescription>
|
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||||
</Alert>
|
<li>Search through your Notion pages and databases</li>
|
||||||
</AccordionContent>
|
<li>Access documents, wikis, and knowledge bases</li>
|
||||||
</AccordionItem>
|
<li>Connect your team's knowledge directly to your search space</li>
|
||||||
</Accordion>
|
<li>Keep your search results up-to-date with latest Notion content</li>
|
||||||
</CardContent>
|
<li>Index your Notion documents for enhanced search capabilities</li>
|
||||||
</Card>
|
</ul>
|
||||||
</TabsContent>
|
</CardFooter>
|
||||||
</Tabs>
|
</Card>
|
||||||
</motion.div>
|
</TabsContent>
|
||||||
</div>
|
|
||||||
);
|
<TabsContent value="documentation">
|
||||||
|
<Card className="border-2 border-border">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl font-bold">Notion Connector Documentation</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Learn how to set up and use the Notion connector to index your workspace data.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
The Notion connector uses the Notion search API to fetch all pages that the
|
||||||
|
connector has access to within a workspace.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||||
|
<li>
|
||||||
|
For follow up indexing runs, the connector only retrieves pages that have been
|
||||||
|
updated since the last indexing attempt.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Indexing is configured to run every <strong>10 minutes</strong>, so page
|
||||||
|
updates should appear within 10 minutes.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
<AccordionItem value="authorization">
|
||||||
|
<AccordionTrigger className="text-lg font-medium">
|
||||||
|
Authorization
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4">
|
||||||
|
<Alert className="bg-muted">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>No Admin Access Required</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
There's no requirement to be an Admin to share information with an
|
||||||
|
integration. Any member can share pages and databases with it.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">Step 1: Create an integration</h4>
|
||||||
|
<ol className="list-decimal pl-5 space-y-3">
|
||||||
|
<li>
|
||||||
|
Visit{" "}
|
||||||
|
<a
|
||||||
|
href="https://www.notion.com/my-integrations"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
https://www.notion.com/my-integrations
|
||||||
|
</a>{" "}
|
||||||
|
in your browser.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Click the <strong>+ New integration</strong> button.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Name the integration (something like "Search Connector" could work).
|
||||||
|
</li>
|
||||||
|
<li>Select "Read content" as the only capability required.</li>
|
||||||
|
<li>
|
||||||
|
Click <strong>Submit</strong> to create the integration.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
On the next page, you'll find your Notion integration token. Make a
|
||||||
|
copy of it as you'll need it to configure the connector.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium mb-2">
|
||||||
|
Step 2: Share pages/databases with your integration
|
||||||
|
</h4>
|
||||||
|
<p className="text-muted-foreground mb-3">
|
||||||
|
To keep your information secure, integrations don't have access to any
|
||||||
|
pages or databases in the workspace at first. You must share specific
|
||||||
|
pages with an integration in order for the connector to access those
|
||||||
|
pages.
|
||||||
|
</p>
|
||||||
|
<ol className="list-decimal pl-5 space-y-3">
|
||||||
|
<li>Go to the page/database in your workspace.</li>
|
||||||
|
<li>
|
||||||
|
Click the <code>•••</code> on the top right corner of the page.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Scroll to the bottom of the pop-up and click{" "}
|
||||||
|
<strong>Add connections</strong>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Search for and select the new integration in the{" "}
|
||||||
|
<code>Search for connections...</code> menu.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Important:</strong>
|
||||||
|
<ul className="list-disc pl-5 mt-1">
|
||||||
|
<li>
|
||||||
|
If you've added a page, all child pages also become accessible.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
If you've added a database, all rows (and their children) become
|
||||||
|
accessible.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="indexing">
|
||||||
|
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4">
|
||||||
|
<ol className="list-decimal pl-5 space-y-3">
|
||||||
|
<li>
|
||||||
|
Navigate to the Connector Dashboard and select the <strong>Notion</strong>{" "}
|
||||||
|
Connector.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Place the <strong>Integration Token</strong> under{" "}
|
||||||
|
<strong>Step 1 Provide Credentials</strong>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Click <strong>Connect</strong> to establish the connection.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<Alert className="bg-muted">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>Indexing Behavior</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
The Notion connector currently indexes everything it has access to. If you
|
||||||
|
want to limit specific content being indexed, simply unshare the database
|
||||||
|
from Notion with the integration.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,408 +1,370 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
IconBook,
|
||||||
CardContent,
|
IconBrandDiscord,
|
||||||
CardFooter,
|
IconBrandGithub,
|
||||||
CardHeader,
|
IconBrandNotion,
|
||||||
} from "@/components/ui/card";
|
IconBrandSlack,
|
||||||
import {
|
IconBrandWindows,
|
||||||
Collapsible,
|
IconBrandZoom,
|
||||||
CollapsibleContent,
|
IconChevronDown,
|
||||||
CollapsibleTrigger,
|
IconChevronRight,
|
||||||
} from "@/components/ui/collapsible";
|
IconLayoutKanban,
|
||||||
import {
|
IconLinkPlus,
|
||||||
IconBook,
|
IconMail,
|
||||||
IconBrandDiscord,
|
IconTicket,
|
||||||
IconBrandGithub,
|
IconWorldWww,
|
||||||
IconBrandNotion,
|
|
||||||
IconBrandSlack,
|
|
||||||
IconBrandWindows,
|
|
||||||
IconBrandZoom,
|
|
||||||
IconChevronDown,
|
|
||||||
IconChevronRight,
|
|
||||||
IconMail,
|
|
||||||
IconWorldWww,
|
|
||||||
IconTicket,
|
|
||||||
IconLayoutKanban,
|
|
||||||
IconLinkPlus,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion, type Variants } from "framer-motion";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
|
||||||
// Define the Connector type
|
// Define the Connector type
|
||||||
interface Connector {
|
interface Connector {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
status: "available" | "coming-soon" | "connected";
|
status: "available" | "coming-soon" | "connected";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConnectorCategory {
|
interface ConnectorCategory {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
connectors: Connector[];
|
connectors: Connector[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define connector categories and their connectors
|
// Define connector categories and their connectors
|
||||||
const connectorCategories: ConnectorCategory[] = [
|
const connectorCategories: ConnectorCategory[] = [
|
||||||
{
|
{
|
||||||
id: "search-engines",
|
id: "search-engines",
|
||||||
title: "Search Engines",
|
title: "Search Engines",
|
||||||
connectors: [
|
connectors: [
|
||||||
{
|
{
|
||||||
id: "tavily-api",
|
id: "tavily-api",
|
||||||
title: "Tavily API",
|
title: "Tavily API",
|
||||||
description: "Search the web using the Tavily API",
|
description: "Search the web using the Tavily API",
|
||||||
icon: <IconWorldWww className="h-6 w-6" />,
|
icon: <IconWorldWww className="h-6 w-6" />,
|
||||||
status: "available",
|
status: "available",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "linkup-api",
|
id: "linkup-api",
|
||||||
title: "Linkup API",
|
title: "Linkup API",
|
||||||
description: "Search the web using the Linkup API",
|
description: "Search the web using the Linkup API",
|
||||||
icon: <IconLinkPlus className="h-6 w-6" />,
|
icon: <IconLinkPlus className="h-6 w-6" />,
|
||||||
status: "available",
|
status: "available",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "team-chats",
|
id: "team-chats",
|
||||||
title: "Team Chats",
|
title: "Team Chats",
|
||||||
connectors: [
|
connectors: [
|
||||||
{
|
{
|
||||||
id: "slack-connector",
|
id: "slack-connector",
|
||||||
title: "Slack",
|
title: "Slack",
|
||||||
description:
|
description: "Connect to your Slack workspace to access messages and channels.",
|
||||||
"Connect to your Slack workspace to access messages and channels.",
|
icon: <IconBrandSlack className="h-6 w-6" />,
|
||||||
icon: <IconBrandSlack className="h-6 w-6" />,
|
status: "available",
|
||||||
status: "available",
|
},
|
||||||
},
|
{
|
||||||
{
|
id: "ms-teams",
|
||||||
id: "ms-teams",
|
title: "Microsoft Teams",
|
||||||
title: "Microsoft Teams",
|
description: "Connect to Microsoft Teams to access your team's conversations.",
|
||||||
description:
|
icon: <IconBrandWindows className="h-6 w-6" />,
|
||||||
"Connect to Microsoft Teams to access your team's conversations.",
|
status: "coming-soon",
|
||||||
icon: <IconBrandWindows className="h-6 w-6" />,
|
},
|
||||||
status: "coming-soon",
|
{
|
||||||
},
|
id: "discord-connector",
|
||||||
{
|
title: "Discord",
|
||||||
id: "discord-connector",
|
description: "Connect to Discord servers to access messages and channels.",
|
||||||
title: "Discord",
|
icon: <IconBrandDiscord className="h-6 w-6" />,
|
||||||
description:
|
status: "available",
|
||||||
"Connect to Discord servers to access messages and channels.",
|
},
|
||||||
icon: <IconBrandDiscord className="h-6 w-6" />,
|
],
|
||||||
status: "available",
|
},
|
||||||
},
|
{
|
||||||
],
|
id: "project-management",
|
||||||
},
|
title: "Project Management",
|
||||||
{
|
connectors: [
|
||||||
id: "project-management",
|
{
|
||||||
title: "Project Management",
|
id: "linear-connector",
|
||||||
connectors: [
|
title: "Linear",
|
||||||
{
|
description: "Connect to Linear to search issues, comments and project data.",
|
||||||
id: "linear-connector",
|
icon: <IconLayoutKanban className="h-6 w-6" />,
|
||||||
title: "Linear",
|
status: "available",
|
||||||
description:
|
},
|
||||||
"Connect to Linear to search issues, comments and project data.",
|
{
|
||||||
icon: <IconLayoutKanban className="h-6 w-6" />,
|
id: "jira-connector",
|
||||||
status: "available",
|
title: "Jira",
|
||||||
},
|
description: "Connect to Jira to search issues, tickets and project data.",
|
||||||
{
|
icon: <IconTicket className="h-6 w-6" />,
|
||||||
id: "jira-connector",
|
status: "available",
|
||||||
title: "Jira",
|
},
|
||||||
description:
|
],
|
||||||
"Connect to Jira to search issues, tickets and project data.",
|
},
|
||||||
icon: <IconTicket className="h-6 w-6" />,
|
{
|
||||||
status: "available",
|
id: "knowledge-bases",
|
||||||
},
|
title: "Knowledge Bases",
|
||||||
],
|
connectors: [
|
||||||
},
|
{
|
||||||
{
|
id: "notion-connector",
|
||||||
id: "knowledge-bases",
|
title: "Notion",
|
||||||
title: "Knowledge Bases",
|
description: "Connect to your Notion workspace to access pages and databases.",
|
||||||
connectors: [
|
icon: <IconBrandNotion className="h-6 w-6" />,
|
||||||
{
|
status: "available",
|
||||||
id: "notion-connector",
|
},
|
||||||
title: "Notion",
|
{
|
||||||
description:
|
id: "github-connector",
|
||||||
"Connect to your Notion workspace to access pages and databases.",
|
title: "GitHub",
|
||||||
icon: <IconBrandNotion className="h-6 w-6" />,
|
description: "Connect a GitHub PAT to index code and docs from accessible repositories.",
|
||||||
status: "available",
|
icon: <IconBrandGithub className="h-6 w-6" />,
|
||||||
},
|
status: "available",
|
||||||
{
|
},
|
||||||
id: "github-connector",
|
{
|
||||||
title: "GitHub",
|
id: "confluence-connector",
|
||||||
description:
|
title: "Confluence",
|
||||||
"Connect a GitHub PAT to index code and docs from accessible repositories.",
|
description: "Connect to Confluence to search pages, comments and documentation.",
|
||||||
icon: <IconBrandGithub className="h-6 w-6" />,
|
icon: <IconBook className="h-6 w-6" />,
|
||||||
status: "available",
|
status: "available",
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
id: "confluence-connector",
|
},
|
||||||
title: "Confluence",
|
{
|
||||||
description:
|
id: "communication",
|
||||||
"Connect to Confluence to search pages, comments and documentation.",
|
title: "Communication",
|
||||||
icon: <IconBook className="h-6 w-6" />,
|
connectors: [
|
||||||
status: "available",
|
{
|
||||||
},
|
id: "gmail",
|
||||||
],
|
title: "Gmail",
|
||||||
},
|
description: "Connect to your Gmail account to access emails.",
|
||||||
{
|
icon: <IconMail className="h-6 w-6" />,
|
||||||
id: "communication",
|
status: "coming-soon",
|
||||||
title: "Communication",
|
},
|
||||||
connectors: [
|
{
|
||||||
{
|
id: "zoom",
|
||||||
id: "gmail",
|
title: "Zoom",
|
||||||
title: "Gmail",
|
description: "Connect to Zoom to access meeting recordings and transcripts.",
|
||||||
description: "Connect to your Gmail account to access emails.",
|
icon: <IconBrandZoom className="h-6 w-6" />,
|
||||||
icon: <IconMail className="h-6 w-6" />,
|
status: "coming-soon",
|
||||||
status: "coming-soon",
|
},
|
||||||
},
|
],
|
||||||
{
|
},
|
||||||
id: "zoom",
|
|
||||||
title: "Zoom",
|
|
||||||
description:
|
|
||||||
"Connect to Zoom to access meeting recordings and transcripts.",
|
|
||||||
icon: <IconBrandZoom className="h-6 w-6" />,
|
|
||||||
status: "coming-soon",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Animation variants
|
// Animation variants
|
||||||
const fadeIn = {
|
const fadeIn = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
visible: { opacity: 1, transition: { duration: 0.4 } },
|
visible: { opacity: 1, transition: { duration: 0.4 } },
|
||||||
};
|
};
|
||||||
|
|
||||||
const staggerContainer = {
|
const staggerContainer = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: {
|
||||||
staggerChildren: 0.1,
|
staggerChildren: 0.1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const cardVariants = {
|
const cardVariants: Variants = {
|
||||||
hidden: { opacity: 0, y: 20 },
|
hidden: { opacity: 0, y: 20 },
|
||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
transition: {
|
transition: {
|
||||||
type: "spring",
|
type: "spring",
|
||||||
stiffness: 260,
|
stiffness: 260,
|
||||||
damping: 20,
|
damping: 20,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
hover: {
|
hover: {
|
||||||
scale: 1.02,
|
scale: 1.02,
|
||||||
boxShadow:
|
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
|
||||||
"0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
|
transition: {
|
||||||
transition: {
|
type: "spring",
|
||||||
type: "spring",
|
stiffness: 400,
|
||||||
stiffness: 400,
|
damping: 10,
|
||||||
damping: 10,
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ConnectorsPage() {
|
export default function ConnectorsPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchSpaceId = params.search_space_id as string;
|
const searchSpaceId = params.search_space_id as string;
|
||||||
const [expandedCategories, setExpandedCategories] = useState<string[]>([
|
const [expandedCategories, setExpandedCategories] = useState<string[]>([
|
||||||
"search-engines",
|
"search-engines",
|
||||||
"knowledge-bases",
|
"knowledge-bases",
|
||||||
"project-management",
|
"project-management",
|
||||||
"team-chats",
|
"team-chats",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const toggleCategory = (categoryId: string) => {
|
const toggleCategory = (categoryId: string) => {
|
||||||
setExpandedCategories((prev) =>
|
setExpandedCategories((prev) =>
|
||||||
prev.includes(categoryId)
|
prev.includes(categoryId) ? prev.filter((id) => id !== categoryId) : [...prev, categoryId]
|
||||||
? prev.filter((id) => id !== categoryId)
|
);
|
||||||
: [...prev, categoryId],
|
};
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-12 max-w-6xl">
|
<div className="container mx-auto py-12 max-w-6xl">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{
|
transition={{
|
||||||
duration: 0.6,
|
duration: 0.6,
|
||||||
ease: [0.22, 1, 0.36, 1],
|
ease: [0.22, 1, 0.36, 1],
|
||||||
}}
|
}}
|
||||||
className="mb-12 text-center"
|
className="mb-12 text-center"
|
||||||
>
|
>
|
||||||
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-indigo-500 to-purple-500 bg-clip-text text-transparent">
|
<h1 className="text-4xl font-bold tracking-tight bg-gradient-to-r from-indigo-500 to-purple-500 bg-clip-text text-transparent">
|
||||||
Connect Your Tools
|
Connect Your Tools
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-3 text-lg max-w-2xl mx-auto">
|
<p className="text-muted-foreground mt-3 text-lg max-w-2xl mx-auto">
|
||||||
Integrate with your favorite services to enhance your research
|
Integrate with your favorite services to enhance your research capabilities.
|
||||||
capabilities.
|
</p>
|
||||||
</p>
|
</motion.div>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="space-y-8"
|
className="space-y-8"
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
variants={staggerContainer}
|
variants={staggerContainer}
|
||||||
>
|
>
|
||||||
{connectorCategories.map((category) => (
|
{connectorCategories.map((category) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={category.id}
|
key={category.id}
|
||||||
variants={fadeIn}
|
variants={fadeIn}
|
||||||
className="rounded-lg border bg-card text-card-foreground shadow-sm"
|
className="rounded-lg border bg-card text-card-foreground shadow-sm"
|
||||||
>
|
>
|
||||||
<Collapsible
|
<Collapsible
|
||||||
open={expandedCategories.includes(category.id)}
|
open={expandedCategories.includes(category.id)}
|
||||||
onOpenChange={() => toggleCategory(category.id)}
|
onOpenChange={() => toggleCategory(category.id)}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between space-x-4 p-4">
|
<div className="flex items-center justify-between space-x-4 p-4">
|
||||||
<h3 className="text-xl font-semibold">{category.title}</h3>
|
<h3 className="text-xl font-semibold">{category.title}</h3>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<Button
|
<Button variant="ghost" size="sm" className="w-9 p-0 hover:bg-muted">
|
||||||
variant="ghost"
|
<motion.div
|
||||||
size="sm"
|
animate={{
|
||||||
className="w-9 p-0 hover:bg-muted"
|
rotate: expandedCategories.includes(category.id) ? 180 : 0,
|
||||||
>
|
}}
|
||||||
<motion.div
|
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||||
animate={{
|
>
|
||||||
rotate: expandedCategories.includes(category.id)
|
<IconChevronDown className="h-5 w-5" />
|
||||||
? 180
|
</motion.div>
|
||||||
: 0,
|
<span className="sr-only">Toggle</span>
|
||||||
}}
|
</Button>
|
||||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
</CollapsibleTrigger>
|
||||||
>
|
</div>
|
||||||
<IconChevronDown className="h-5 w-5" />
|
|
||||||
</motion.div>
|
|
||||||
<span className="sr-only">Toggle</span>
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 p-4"
|
className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 p-4"
|
||||||
variants={staggerContainer}
|
variants={staggerContainer}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
exit="hidden"
|
exit="hidden"
|
||||||
>
|
>
|
||||||
{category.connectors.map((connector) => (
|
{category.connectors.map((connector) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={connector.id}
|
key={connector.id}
|
||||||
variants={cardVariants}
|
variants={cardVariants}
|
||||||
whileHover="hover"
|
whileHover="hover"
|
||||||
className="col-span-1"
|
className="col-span-1"
|
||||||
>
|
>
|
||||||
<Card className="h-full flex flex-col overflow-hidden border-transparent transition-all duration-200 hover:border-primary/50">
|
<Card className="h-full flex flex-col overflow-hidden border-transparent transition-all duration-200 hover:border-primary/50">
|
||||||
<CardHeader className="flex-row items-center gap-4 pb-2">
|
<CardHeader className="flex-row items-center gap-4 pb-2">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 dark:bg-primary/20">
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10 dark:bg-primary/20">
|
||||||
<motion.div
|
<motion.div
|
||||||
whileHover={{ rotate: 5, scale: 1.1 }}
|
whileHover={{ rotate: 5, scale: 1.1 }}
|
||||||
className="text-primary"
|
className="text-primary"
|
||||||
>
|
>
|
||||||
{connector.icon}
|
{connector.icon}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-medium">
|
<h3 className="font-medium">{connector.title}</h3>
|
||||||
{connector.title}
|
{connector.status === "coming-soon" && (
|
||||||
</h3>
|
<Badge
|
||||||
{connector.status === "coming-soon" && (
|
variant="outline"
|
||||||
<Badge
|
className="text-xs bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-300 border-amber-200 dark:border-amber-800"
|
||||||
variant="outline"
|
>
|
||||||
className="text-xs bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-300 border-amber-200 dark:border-amber-800"
|
Coming soon
|
||||||
>
|
</Badge>
|
||||||
Coming soon
|
)}
|
||||||
</Badge>
|
{connector.status === "connected" && (
|
||||||
)}
|
<Badge
|
||||||
{connector.status === "connected" && (
|
variant="outline"
|
||||||
<Badge
|
className="text-xs bg-green-100 dark:bg-green-950 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800"
|
||||||
variant="outline"
|
>
|
||||||
className="text-xs bg-green-100 dark:bg-green-950 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800"
|
Connected
|
||||||
>
|
</Badge>
|
||||||
Connected
|
)}
|
||||||
</Badge>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="pb-4">
|
<CardContent className="pb-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{connector.description}</p>
|
||||||
{connector.description}
|
</CardContent>
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
|
|
||||||
<CardFooter className="mt-auto pt-2">
|
<CardFooter className="mt-auto pt-2">
|
||||||
{connector.status === "available" && (
|
{connector.status === "available" && (
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`}
|
href={`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<Button
|
<Button variant="default" className="w-full group">
|
||||||
variant="default"
|
<span>Connect</span>
|
||||||
className="w-full group"
|
<motion.div
|
||||||
>
|
className="ml-1"
|
||||||
<span>Connect</span>
|
initial={{ x: 0 }}
|
||||||
<motion.div
|
whileHover={{ x: 3 }}
|
||||||
className="ml-1"
|
transition={{
|
||||||
initial={{ x: 0 }}
|
type: "spring",
|
||||||
whileHover={{ x: 3 }}
|
stiffness: 400,
|
||||||
transition={{
|
damping: 10,
|
||||||
type: "spring",
|
}}
|
||||||
stiffness: 400,
|
>
|
||||||
damping: 10,
|
<IconChevronRight className="h-4 w-4" />
|
||||||
}}
|
</motion.div>
|
||||||
>
|
</Button>
|
||||||
<IconChevronRight className="h-4 w-4" />
|
</Link>
|
||||||
</motion.div>
|
)}
|
||||||
</Button>
|
{connector.status === "coming-soon" && (
|
||||||
</Link>
|
<Button variant="outline" disabled className="w-full opacity-70">
|
||||||
)}
|
Coming Soon
|
||||||
{connector.status === "coming-soon" && (
|
</Button>
|
||||||
<Button
|
)}
|
||||||
variant="outline"
|
{connector.status === "connected" && (
|
||||||
disabled
|
<Button
|
||||||
className="w-full opacity-70"
|
variant="outline"
|
||||||
>
|
className="w-full border-green-500 text-green-600 hover:bg-green-50 dark:hover:bg-green-950"
|
||||||
Coming Soon
|
>
|
||||||
</Button>
|
Manage
|
||||||
)}
|
</Button>
|
||||||
{connector.status === "connected" && (
|
)}
|
||||||
<Button
|
</CardFooter>
|
||||||
variant="outline"
|
</Card>
|
||||||
className="w-full border-green-500 text-green-600 hover:bg-green-50 dark:hover:bg-green-950"
|
</motion.div>
|
||||||
>
|
))}
|
||||||
Manage
|
</motion.div>
|
||||||
</Button>
|
</AnimatePresence>
|
||||||
)}
|
</CollapsibleContent>
|
||||||
</CardFooter>
|
</Collapsible>
|
||||||
</Card>
|
</motion.div>
|
||||||
</motion.div>
|
))}
|
||||||
))}
|
</motion.div>
|
||||||
</motion.div>
|
</div>
|
||||||
</AnimatePresence>
|
);
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,207 +1,193 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter, useParams } from "next/navigation";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { motion } from "framer-motion";
|
||||||
import * as z from "zod";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
import { useState } from "react";
|
||||||
import {
|
import { useForm } from "react-hook-form";
|
||||||
Form,
|
import { toast } from "sonner";
|
||||||
FormControl,
|
import * as z from "zod";
|
||||||
FormDescription,
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Form,
|
||||||
AlertDescription,
|
FormControl,
|
||||||
AlertTitle,
|
FormDescription,
|
||||||
} from "@/components/ui/alert";
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||||
|
|
||||||
// Define the form schema with Zod
|
// Define the form schema with Zod
|
||||||
const serperApiFormSchema = z.object({
|
const serperApiFormSchema = z.object({
|
||||||
name: z.string().min(3, {
|
name: z.string().min(3, {
|
||||||
message: "Connector name must be at least 3 characters.",
|
message: "Connector name must be at least 3 characters.",
|
||||||
}),
|
}),
|
||||||
api_key: z.string().min(10, {
|
api_key: z.string().min(10, {
|
||||||
message: "API key is required and must be valid.",
|
message: "API key is required and must be valid.",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Define the type for the form values
|
// Define the type for the form values
|
||||||
type SerperApiFormValues = z.infer<typeof serperApiFormSchema>;
|
type SerperApiFormValues = z.infer<typeof serperApiFormSchema>;
|
||||||
|
|
||||||
export default function SerperApiPage() {
|
export default function SerperApiPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchSpaceId = params.search_space_id as string;
|
const searchSpaceId = params.search_space_id as string;
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { createConnector } = useSearchSourceConnectors();
|
const { createConnector } = useSearchSourceConnectors();
|
||||||
|
|
||||||
// Initialize the form
|
// Initialize the form
|
||||||
const form = useForm<SerperApiFormValues>({
|
const form = useForm<SerperApiFormValues>({
|
||||||
resolver: zodResolver(serperApiFormSchema),
|
resolver: zodResolver(serperApiFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "Serper API Connector",
|
name: "Serper API Connector",
|
||||||
api_key: "",
|
api_key: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
const onSubmit = async (values: SerperApiFormValues) => {
|
const onSubmit = async (values: SerperApiFormValues) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await createConnector({
|
await createConnector({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
connector_type: "SERPER_API",
|
connector_type: "SERPER_API",
|
||||||
config: {
|
config: {
|
||||||
SERPER_API_KEY: values.api_key,
|
SERPER_API_KEY: values.api_key,
|
||||||
},
|
},
|
||||||
is_indexable: false,
|
is_indexable: false,
|
||||||
last_indexed_at: null,
|
last_indexed_at: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Serper API connector created successfully!");
|
toast.success("Serper API connector created successfully!");
|
||||||
|
|
||||||
// Navigate back to connectors page
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating connector:", error);
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
// Navigate back to connectors page
|
||||||
<div className="container mx-auto py-8 max-w-3xl">
|
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||||
<Button
|
} catch (error) {
|
||||||
variant="ghost"
|
console.error("Error creating connector:", error);
|
||||||
className="mb-6"
|
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
} finally {
|
||||||
>
|
setIsSubmitting(false);
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
}
|
||||||
Back to Connectors
|
};
|
||||||
</Button>
|
|
||||||
|
|
||||||
<motion.div
|
return (
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<div className="container mx-auto py-8 max-w-3xl">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<Button
|
||||||
transition={{ duration: 0.5 }}
|
variant="ghost"
|
||||||
>
|
className="mb-6"
|
||||||
<Card className="border-2 border-border">
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||||
<CardHeader>
|
>
|
||||||
<CardTitle className="text-2xl font-bold">Connect Serper API</CardTitle>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<CardDescription>
|
Back to Connectors
|
||||||
Integrate with Serper API to enhance your search capabilities with Google search results.
|
</Button>
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Alert className="mb-6 bg-muted">
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<AlertTitle>API Key Required</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
You'll need a Serper API key to use this connector. You can get one by signing up at{" "}
|
|
||||||
<a
|
|
||||||
href="https://serper.dev"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-medium underline underline-offset-4"
|
|
||||||
>
|
|
||||||
serper.dev
|
|
||||||
</a>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Form {...form}>
|
<motion.div
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<FormField
|
animate={{ opacity: 1, y: 0 }}
|
||||||
control={form.control}
|
transition={{ duration: 0.5 }}
|
||||||
name="name"
|
>
|
||||||
render={({ field }) => (
|
<Card className="border-2 border-border">
|
||||||
<FormItem>
|
<CardHeader>
|
||||||
<FormLabel>Connector Name</FormLabel>
|
<CardTitle className="text-2xl font-bold">Connect Serper API</CardTitle>
|
||||||
<FormControl>
|
<CardDescription>
|
||||||
<Input placeholder="My Serper API Connector" {...field} />
|
Integrate with Serper API to enhance your search capabilities with Google search
|
||||||
</FormControl>
|
results.
|
||||||
<FormDescription>
|
</CardDescription>
|
||||||
A friendly name to identify this connector.
|
</CardHeader>
|
||||||
</FormDescription>
|
<CardContent>
|
||||||
<FormMessage />
|
<Alert className="mb-6 bg-muted">
|
||||||
</FormItem>
|
<Info className="h-4 w-4" />
|
||||||
)}
|
<AlertTitle>API Key Required</AlertTitle>
|
||||||
/>
|
<AlertDescription>
|
||||||
|
You'll need a Serper API key to use this connector. You can get one by signing up at{" "}
|
||||||
|
<a
|
||||||
|
href="https://serper.dev"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
serper.dev
|
||||||
|
</a>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<FormField
|
<Form {...form}>
|
||||||
control={form.control}
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
name="api_key"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="name"
|
||||||
<FormLabel>Serper API Key</FormLabel>
|
render={({ field }) => (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Input
|
<FormLabel>Connector Name</FormLabel>
|
||||||
type="password"
|
<FormControl>
|
||||||
placeholder="Enter your Serper API key"
|
<Input placeholder="My Serper API Connector" {...field} />
|
||||||
{...field}
|
</FormControl>
|
||||||
/>
|
<FormDescription>A friendly name to identify this connector.</FormDescription>
|
||||||
</FormControl>
|
<FormMessage />
|
||||||
<FormDescription>
|
</FormItem>
|
||||||
Your API key will be encrypted and stored securely.
|
)}
|
||||||
</FormDescription>
|
/>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<FormField
|
||||||
<Button
|
control={form.control}
|
||||||
type="submit"
|
name="api_key"
|
||||||
disabled={isSubmitting}
|
render={({ field }) => (
|
||||||
className="w-full sm:w-auto"
|
<FormItem>
|
||||||
>
|
<FormLabel>Serper API Key</FormLabel>
|
||||||
{isSubmitting ? (
|
<FormControl>
|
||||||
<>
|
<Input type="password" placeholder="Enter your Serper API key" {...field} />
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
</FormControl>
|
||||||
Connecting...
|
<FormDescription>
|
||||||
</>
|
Your API key will be encrypted and stored securely.
|
||||||
) : (
|
</FormDescription>
|
||||||
<>
|
<FormMessage />
|
||||||
<Check className="mr-2 h-4 w-4" />
|
</FormItem>
|
||||||
Connect Serper API
|
)}
|
||||||
</>
|
/>
|
||||||
)}
|
|
||||||
</Button>
|
<div className="flex justify-end">
|
||||||
</div>
|
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||||
</form>
|
{isSubmitting ? (
|
||||||
</Form>
|
<>
|
||||||
</CardContent>
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
Connecting...
|
||||||
<h4 className="text-sm font-medium">What you get with Serper API:</h4>
|
</>
|
||||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
) : (
|
||||||
<li>Access to Google search results directly in your research</li>
|
<>
|
||||||
<li>Real-time information from the web</li>
|
<Check className="mr-2 h-4 w-4" />
|
||||||
<li>Enhanced search capabilities for your projects</li>
|
Connect Serper API
|
||||||
</ul>
|
</>
|
||||||
</CardFooter>
|
)}
|
||||||
</Card>
|
</Button>
|
||||||
</motion.div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
);
|
</Form>
|
||||||
}
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||||
|
<h4 className="text-sm font-medium">What you get with Serper API:</h4>
|
||||||
|
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||||
|
<li>Access to Google search results directly in your research</li>
|
||||||
|
<li>Real-time information from the web</li>
|
||||||
|
<li>Enhanced search capabilities for your projects</li>
|
||||||
|
</ul>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,270 +1,285 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter, useParams } from "next/navigation";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { motion } from "framer-motion";
|
||||||
import * as z from "zod";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import * as z from "zod";
|
||||||
import {
|
import {
|
||||||
Form,
|
Accordion,
|
||||||
FormControl,
|
AccordionContent,
|
||||||
FormDescription,
|
AccordionItem,
|
||||||
FormField,
|
AccordionTrigger,
|
||||||
FormItem,
|
} from "@/components/ui/accordion";
|
||||||
FormLabel,
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Form,
|
||||||
AlertDescription,
|
FormControl,
|
||||||
AlertTitle,
|
FormDescription,
|
||||||
} from "@/components/ui/alert";
|
FormField,
|
||||||
import {
|
FormItem,
|
||||||
Accordion,
|
FormLabel,
|
||||||
AccordionContent,
|
FormMessage,
|
||||||
AccordionItem,
|
} from "@/components/ui/form";
|
||||||
AccordionTrigger,
|
import { Input } from "@/components/ui/input";
|
||||||
} from "@/components/ui/accordion";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||||
|
|
||||||
// Define the form schema with Zod
|
// Define the form schema with Zod
|
||||||
const slackConnectorFormSchema = z.object({
|
const slackConnectorFormSchema = z.object({
|
||||||
name: z.string().min(3, {
|
name: z.string().min(3, {
|
||||||
message: "Connector name must be at least 3 characters.",
|
message: "Connector name must be at least 3 characters.",
|
||||||
}),
|
}),
|
||||||
bot_token: z.string().min(10, {
|
bot_token: z.string().min(10, {
|
||||||
message: "Bot User OAuth Token is required and must be valid.",
|
message: "Bot User OAuth Token is required and must be valid.",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Define the type for the form values
|
// Define the type for the form values
|
||||||
type SlackConnectorFormValues = z.infer<typeof slackConnectorFormSchema>;
|
type SlackConnectorFormValues = z.infer<typeof slackConnectorFormSchema>;
|
||||||
|
|
||||||
export default function SlackConnectorPage() {
|
export default function SlackConnectorPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchSpaceId = params.search_space_id as string;
|
const searchSpaceId = params.search_space_id as string;
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { createConnector } = useSearchSourceConnectors();
|
const { createConnector } = useSearchSourceConnectors();
|
||||||
|
|
||||||
// Initialize the form
|
// Initialize the form
|
||||||
const form = useForm<SlackConnectorFormValues>({
|
const form = useForm<SlackConnectorFormValues>({
|
||||||
resolver: zodResolver(slackConnectorFormSchema),
|
resolver: zodResolver(slackConnectorFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "Slack Connector",
|
name: "Slack Connector",
|
||||||
bot_token: "",
|
bot_token: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
const onSubmit = async (values: SlackConnectorFormValues) => {
|
const onSubmit = async (values: SlackConnectorFormValues) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await createConnector({
|
await createConnector({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
connector_type: "SLACK_CONNECTOR",
|
connector_type: "SLACK_CONNECTOR",
|
||||||
config: {
|
config: {
|
||||||
SLACK_BOT_TOKEN: values.bot_token,
|
SLACK_BOT_TOKEN: values.bot_token,
|
||||||
},
|
},
|
||||||
is_indexable: true,
|
is_indexable: true,
|
||||||
last_indexed_at: null,
|
last_indexed_at: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Slack connector created successfully!");
|
toast.success("Slack connector created successfully!");
|
||||||
|
|
||||||
// Navigate back to connectors page
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating connector:", error);
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
// Navigate back to connectors page
|
||||||
<div className="container mx-auto py-8 max-w-3xl">
|
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||||
<Button
|
} catch (error) {
|
||||||
variant="ghost"
|
console.error("Error creating connector:", error);
|
||||||
className="mb-6"
|
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
} finally {
|
||||||
>
|
setIsSubmitting(false);
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
}
|
||||||
Back to Connectors
|
};
|
||||||
</Button>
|
|
||||||
|
|
||||||
<motion.div
|
return (
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<div className="container mx-auto py-8 max-w-3xl">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<Button
|
||||||
transition={{ duration: 0.5 }}
|
variant="ghost"
|
||||||
>
|
className="mb-6"
|
||||||
<Tabs defaultValue="connect" className="w-full">
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
>
|
||||||
<TabsTrigger value="connect">Connect</TabsTrigger>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
Back to Connectors
|
||||||
</TabsList>
|
</Button>
|
||||||
|
|
||||||
<TabsContent value="connect">
|
|
||||||
<Card className="border-2 border-border">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-2xl font-bold">Connect Slack Workspace</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Integrate with Slack to search and retrieve information from your workspace channels and conversations. This connector can index your Slack messages for search.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Alert className="mb-6 bg-muted">
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<AlertTitle>Bot User OAuth Token Required</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
You'll need a Slack Bot User OAuth Token to use this connector. You can create a Slack app and get the token from{" "}
|
|
||||||
<a
|
|
||||||
href="https://api.slack.com/apps"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-medium underline underline-offset-4"
|
|
||||||
>
|
|
||||||
Slack API Dashboard
|
|
||||||
</a>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Form {...form}>
|
<motion.div
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<FormField
|
animate={{ opacity: 1, y: 0 }}
|
||||||
control={form.control}
|
transition={{ duration: 0.5 }}
|
||||||
name="name"
|
>
|
||||||
render={({ field }) => (
|
<Tabs defaultValue="connect" className="w-full">
|
||||||
<FormItem>
|
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||||
<FormLabel>Connector Name</FormLabel>
|
<TabsTrigger value="connect">Connect</TabsTrigger>
|
||||||
<FormControl>
|
<TabsTrigger value="documentation">Documentation</TabsTrigger>
|
||||||
<Input placeholder="My Slack Connector" {...field} />
|
</TabsList>
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
A friendly name to identify this connector.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<TabsContent value="connect">
|
||||||
control={form.control}
|
<Card className="border-2 border-border">
|
||||||
name="bot_token"
|
<CardHeader>
|
||||||
render={({ field }) => (
|
<CardTitle className="text-2xl font-bold">Connect Slack Workspace</CardTitle>
|
||||||
<FormItem>
|
<CardDescription>
|
||||||
<FormLabel>Slack Bot User OAuth Token</FormLabel>
|
Integrate with Slack to search and retrieve information from your workspace
|
||||||
<FormControl>
|
channels and conversations. This connector can index your Slack messages for
|
||||||
<Input
|
search.
|
||||||
type="password"
|
</CardDescription>
|
||||||
placeholder="xoxb-..."
|
</CardHeader>
|
||||||
{...field}
|
<CardContent>
|
||||||
/>
|
<Alert className="mb-6 bg-muted">
|
||||||
</FormControl>
|
<Info className="h-4 w-4" />
|
||||||
<FormDescription>
|
<AlertTitle>Bot User OAuth Token Required</AlertTitle>
|
||||||
Your Bot User OAuth Token will be encrypted and stored securely. It typically starts with "xoxb-".
|
<AlertDescription>
|
||||||
</FormDescription>
|
You'll need a Slack Bot User OAuth Token to use this connector. You can create a
|
||||||
<FormMessage />
|
Slack app and get the token from{" "}
|
||||||
</FormItem>
|
<a
|
||||||
)}
|
href="https://api.slack.com/apps"
|
||||||
/>
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Slack API Dashboard
|
||||||
|
</a>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<Form {...form}>
|
||||||
<Button
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
type="submit"
|
<FormField
|
||||||
disabled={isSubmitting}
|
control={form.control}
|
||||||
className="w-full sm:w-auto"
|
name="name"
|
||||||
>
|
render={({ field }) => (
|
||||||
{isSubmitting ? (
|
<FormItem>
|
||||||
<>
|
<FormLabel>Connector Name</FormLabel>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<FormControl>
|
||||||
Connecting...
|
<Input placeholder="My Slack Connector" {...field} />
|
||||||
</>
|
</FormControl>
|
||||||
) : (
|
<FormDescription>
|
||||||
<>
|
A friendly name to identify this connector.
|
||||||
<Check className="mr-2 h-4 w-4" />
|
</FormDescription>
|
||||||
Connect Slack
|
<FormMessage />
|
||||||
</>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
|
||||||
</form>
|
<FormField
|
||||||
</Form>
|
control={form.control}
|
||||||
</CardContent>
|
name="bot_token"
|
||||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
render={({ field }) => (
|
||||||
<h4 className="text-sm font-medium">What you get with Slack integration:</h4>
|
<FormItem>
|
||||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
<FormLabel>Slack Bot User OAuth Token</FormLabel>
|
||||||
<li>Search through your Slack channels and conversations</li>
|
<FormControl>
|
||||||
<li>Access historical messages and shared files</li>
|
<Input type="password" placeholder="xoxb-..." {...field} />
|
||||||
<li>Connect your team's knowledge directly to your search space</li>
|
</FormControl>
|
||||||
<li>Keep your search results up-to-date with latest communications</li>
|
<FormDescription>
|
||||||
<li>Index your Slack messages for enhanced search capabilities</li>
|
Your Bot User OAuth Token will be encrypted and stored securely. It
|
||||||
</ul>
|
typically starts with "xoxb-".
|
||||||
</CardFooter>
|
</FormDescription>
|
||||||
</Card>
|
<FormMessage />
|
||||||
</TabsContent>
|
</FormItem>
|
||||||
|
)}
|
||||||
<TabsContent value="documentation">
|
/>
|
||||||
<Card className="border-2 border-border">
|
|
||||||
<CardHeader>
|
<div className="flex justify-end">
|
||||||
<CardTitle className="text-2xl font-bold">Slack Connector Documentation</CardTitle>
|
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||||
<CardDescription>
|
{isSubmitting ? (
|
||||||
Learn how to set up and use the Slack connector to index your workspace data.
|
<>
|
||||||
</CardDescription>
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
</CardHeader>
|
Connecting...
|
||||||
<CardContent className="space-y-6">
|
</>
|
||||||
<div>
|
) : (
|
||||||
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
<>
|
||||||
<p className="text-muted-foreground">
|
<Check className="mr-2 h-4 w-4" />
|
||||||
The Slack connector indexes all public channels for a given workspace.
|
Connect Slack
|
||||||
</p>
|
</>
|
||||||
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
)}
|
||||||
<li>Upcoming: Support for private channels by tagging/adding the Slack Bot to private channels.</li>
|
</Button>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
|
</Form>
|
||||||
<Accordion type="single" collapsible className="w-full">
|
</CardContent>
|
||||||
<AccordionItem value="authorization">
|
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||||
<AccordionTrigger className="text-lg font-medium">Authorization</AccordionTrigger>
|
<h4 className="text-sm font-medium">What you get with Slack integration:</h4>
|
||||||
<AccordionContent className="space-y-4">
|
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||||
<Alert className="bg-muted">
|
<li>Search through your Slack channels and conversations</li>
|
||||||
<Info className="h-4 w-4" />
|
<li>Access historical messages and shared files</li>
|
||||||
<AlertTitle>Admin Access Required</AlertTitle>
|
<li>Connect your team's knowledge directly to your search space</li>
|
||||||
<AlertDescription>
|
<li>Keep your search results up-to-date with latest communications</li>
|
||||||
You must be an admin of the Slack workspace to set up the connector.
|
<li>Index your Slack messages for enhanced search capabilities</li>
|
||||||
</AlertDescription>
|
</ul>
|
||||||
</Alert>
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
<ol className="list-decimal pl-5 space-y-3">
|
</TabsContent>
|
||||||
<li>Navigate and sign in to <a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-4">https://api.slack.com/apps</a>.</li>
|
|
||||||
<li>
|
<TabsContent value="documentation">
|
||||||
Create a new Slack app:
|
<Card className="border-2 border-border">
|
||||||
<ul className="list-disc pl-5 mt-1">
|
<CardHeader>
|
||||||
<li>Click the <strong>Create New App</strong> button in the top right.</li>
|
<CardTitle className="text-2xl font-bold">Slack Connector Documentation</CardTitle>
|
||||||
<li>Select <strong>From an app manifest</strong> option.</li>
|
<CardDescription>
|
||||||
<li>Select the relevant workspace from the dropdown and click <strong>Next</strong>.</li>
|
Learn how to set up and use the Slack connector to index your workspace data.
|
||||||
</ul>
|
</CardDescription>
|
||||||
</li>
|
</CardHeader>
|
||||||
<li>
|
<CardContent className="space-y-6">
|
||||||
Select the "YAML" tab, paste the following manifest into the text box, and click <strong>Next</strong>:
|
<div>
|
||||||
<div className="bg-muted p-4 rounded-md mt-2 overflow-x-auto">
|
<h3 className="text-xl font-semibold mb-2">How it works</h3>
|
||||||
<pre className="text-xs">
|
<p className="text-muted-foreground">
|
||||||
{`display_information:
|
The Slack connector indexes all public channels for a given workspace.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
|
||||||
|
<li>
|
||||||
|
Upcoming: Support for private channels by tagging/adding the Slack Bot to
|
||||||
|
private channels.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
<AccordionItem value="authorization">
|
||||||
|
<AccordionTrigger className="text-lg font-medium">
|
||||||
|
Authorization
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-4">
|
||||||
|
<Alert className="bg-muted">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>Admin Access Required</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
You must be an admin of the Slack workspace to set up the connector.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<ol className="list-decimal pl-5 space-y-3">
|
||||||
|
<li>
|
||||||
|
Navigate and sign in to{" "}
|
||||||
|
<a
|
||||||
|
href="https://api.slack.com/apps"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
https://api.slack.com/apps
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Create a new Slack app:
|
||||||
|
<ul className="list-disc pl-5 mt-1">
|
||||||
|
<li>
|
||||||
|
Click the <strong>Create New App</strong> button in the top right.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Select <strong>From an app manifest</strong> option.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Select the relevant workspace from the dropdown and click{" "}
|
||||||
|
<strong>Next</strong>.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Select the "YAML" tab, paste the following manifest into the text box, and
|
||||||
|
click <strong>Next</strong>:
|
||||||
|
<div className="bg-muted p-4 rounded-md mt-2 overflow-x-auto">
|
||||||
|
<pre className="text-xs">
|
||||||
|
{`display_information:
|
||||||
name: SlackConnector
|
name: SlackConnector
|
||||||
description: ReadOnly Connector for indexing
|
description: ReadOnly Connector for indexing
|
||||||
features:
|
features:
|
||||||
|
|
@ -287,65 +302,94 @@ settings:
|
||||||
org_deploy_enabled: false
|
org_deploy_enabled: false
|
||||||
socket_mode_enabled: false
|
socket_mode_enabled: false
|
||||||
token_rotation_enabled: false`}
|
token_rotation_enabled: false`}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li>Click the <strong>Create</strong> button.</li>
|
<li>
|
||||||
<li>In the app page, navigate to the <strong>OAuth & Permissions</strong> tab under the <strong>Features</strong> header.</li>
|
Click the <strong>Create</strong> button.
|
||||||
<li>Copy the <strong>Bot User OAuth Token</strong>, this will be used to access Slack.</li>
|
</li>
|
||||||
</ol>
|
<li>
|
||||||
</AccordionContent>
|
In the app page, navigate to the <strong>OAuth & Permissions</strong> tab
|
||||||
</AccordionItem>
|
under the <strong>Features</strong> header.
|
||||||
|
</li>
|
||||||
<AccordionItem value="indexing">
|
<li>
|
||||||
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
Copy the <strong>Bot User OAuth Token</strong>, this will be used to
|
||||||
<AccordionContent className="space-y-4">
|
access Slack.
|
||||||
<ol className="list-decimal pl-5 space-y-3">
|
</li>
|
||||||
<li>Navigate to the Connector Dashboard and select the <strong>Slack</strong> Connector.</li>
|
</ol>
|
||||||
<li>Place the <strong>Bot User OAuth Token</strong> under <strong>Step 1 Provide Credentials</strong>.</li>
|
</AccordionContent>
|
||||||
<li>Click <strong>Connect</strong> to establish the connection.</li>
|
</AccordionItem>
|
||||||
</ol>
|
|
||||||
|
<AccordionItem value="indexing">
|
||||||
<Alert className="bg-muted">
|
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
|
||||||
<Info className="h-4 w-4" />
|
<AccordionContent className="space-y-4">
|
||||||
<AlertTitle>Important: Invite Bot to Channels</AlertTitle>
|
<ol className="list-decimal pl-5 space-y-3">
|
||||||
<AlertDescription>
|
<li>
|
||||||
After connecting, you must invite the bot to each channel you want to index. In each Slack channel, type:
|
Navigate to the Connector Dashboard and select the <strong>Slack</strong>{" "}
|
||||||
<pre className="mt-2 bg-background p-2 rounded-md text-xs">/invite @YourBotName</pre>
|
Connector.
|
||||||
<p className="mt-2">Without this step, you'll get a "not_in_channel" error when the connector tries to access channel messages.</p>
|
</li>
|
||||||
</AlertDescription>
|
<li>
|
||||||
</Alert>
|
Place the <strong>Bot User OAuth Token</strong> under{" "}
|
||||||
|
<strong>Step 1 Provide Credentials</strong>.
|
||||||
<Alert className="bg-muted mt-4">
|
</li>
|
||||||
<Info className="h-4 w-4" />
|
<li>
|
||||||
<AlertTitle>First Indexing</AlertTitle>
|
Click <strong>Connect</strong> to establish the connection.
|
||||||
<AlertDescription>
|
</li>
|
||||||
The first indexing pulls all of the public channels and takes longer than future updates. Only channels where the bot has been invited will be fully indexed.
|
</ol>
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
<Alert className="bg-muted">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
<div className="mt-4">
|
<AlertTitle>Important: Invite Bot to Channels</AlertTitle>
|
||||||
<h4 className="font-medium mb-2">Troubleshooting:</h4>
|
<AlertDescription>
|
||||||
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
|
After connecting, you must invite the bot to each channel you want to
|
||||||
<li>
|
index. In each Slack channel, type:
|
||||||
<strong>not_in_channel error:</strong> If you see this error in logs, it means the bot hasn't been invited to a channel it's trying to access. Use the <code>/invite @YourBotName</code> command in that channel.
|
<pre className="mt-2 bg-background p-2 rounded-md text-xs">
|
||||||
</li>
|
/invite @YourBotName
|
||||||
<li>
|
</pre>
|
||||||
<strong>Alternative approach:</strong> You can add the <code>chat:write.public</code> scope to your Slack app to allow it to access public channels without an explicit invitation.
|
<p className="mt-2">
|
||||||
</li>
|
Without this step, you'll get a "not_in_channel" error when the
|
||||||
<li>
|
connector tries to access channel messages.
|
||||||
<strong>For private channels:</strong> The bot must always be invited using the <code>/invite</code> command.
|
</p>
|
||||||
</li>
|
</AlertDescription>
|
||||||
</ul>
|
</Alert>
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
<Alert className="bg-muted mt-4">
|
||||||
</AccordionItem>
|
<Info className="h-4 w-4" />
|
||||||
</Accordion>
|
<AlertTitle>First Indexing</AlertTitle>
|
||||||
</CardContent>
|
<AlertDescription>
|
||||||
</Card>
|
The first indexing pulls all of the public channels and takes longer than
|
||||||
</TabsContent>
|
future updates. Only channels where the bot has been invited will be fully
|
||||||
</Tabs>
|
indexed.
|
||||||
</motion.div>
|
</AlertDescription>
|
||||||
</div>
|
</Alert>
|
||||||
);
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="font-medium mb-2">Troubleshooting:</h4>
|
||||||
|
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
|
||||||
|
<li>
|
||||||
|
<strong>not_in_channel error:</strong> If you see this error in logs, it
|
||||||
|
means the bot hasn't been invited to a channel it's trying to access.
|
||||||
|
Use the <code>/invite @YourBotName</code> command in that channel.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Alternative approach:</strong> You can add the{" "}
|
||||||
|
<code>chat:write.public</code> scope to your Slack app to allow it to
|
||||||
|
access public channels without an explicit invitation.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>For private channels:</strong> The bot must always be invited
|
||||||
|
using the <code>/invite</code> command.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,207 +1,193 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter, useParams } from "next/navigation";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { motion } from "framer-motion";
|
||||||
import * as z from "zod";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
import { useState } from "react";
|
||||||
import {
|
import { useForm } from "react-hook-form";
|
||||||
Form,
|
import { toast } from "sonner";
|
||||||
FormControl,
|
import * as z from "zod";
|
||||||
FormDescription,
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Form,
|
||||||
AlertDescription,
|
FormControl,
|
||||||
AlertTitle,
|
FormDescription,
|
||||||
} from "@/components/ui/alert";
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||||
|
|
||||||
// Define the form schema with Zod
|
// Define the form schema with Zod
|
||||||
const tavilyApiFormSchema = z.object({
|
const tavilyApiFormSchema = z.object({
|
||||||
name: z.string().min(3, {
|
name: z.string().min(3, {
|
||||||
message: "Connector name must be at least 3 characters.",
|
message: "Connector name must be at least 3 characters.",
|
||||||
}),
|
}),
|
||||||
api_key: z.string().min(10, {
|
api_key: z.string().min(10, {
|
||||||
message: "API key is required and must be valid.",
|
message: "API key is required and must be valid.",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Define the type for the form values
|
// Define the type for the form values
|
||||||
type TavilyApiFormValues = z.infer<typeof tavilyApiFormSchema>;
|
type TavilyApiFormValues = z.infer<typeof tavilyApiFormSchema>;
|
||||||
|
|
||||||
export default function TavilyApiPage() {
|
export default function TavilyApiPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchSpaceId = params.search_space_id as string;
|
const searchSpaceId = params.search_space_id as string;
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const { createConnector } = useSearchSourceConnectors();
|
const { createConnector } = useSearchSourceConnectors();
|
||||||
|
|
||||||
// Initialize the form
|
// Initialize the form
|
||||||
const form = useForm<TavilyApiFormValues>({
|
const form = useForm<TavilyApiFormValues>({
|
||||||
resolver: zodResolver(tavilyApiFormSchema),
|
resolver: zodResolver(tavilyApiFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "Tavily API Connector",
|
name: "Tavily API Connector",
|
||||||
api_key: "",
|
api_key: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
const onSubmit = async (values: TavilyApiFormValues) => {
|
const onSubmit = async (values: TavilyApiFormValues) => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await createConnector({
|
await createConnector({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
connector_type: "TAVILY_API",
|
connector_type: "TAVILY_API",
|
||||||
config: {
|
config: {
|
||||||
TAVILY_API_KEY: values.api_key,
|
TAVILY_API_KEY: values.api_key,
|
||||||
},
|
},
|
||||||
is_indexable: false,
|
is_indexable: false,
|
||||||
last_indexed_at: null,
|
last_indexed_at: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Tavily API connector created successfully!");
|
toast.success("Tavily API connector created successfully!");
|
||||||
|
|
||||||
// Navigate back to connectors page
|
|
||||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating connector:", error);
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
// Navigate back to connectors page
|
||||||
<div className="container mx-auto py-8 max-w-3xl">
|
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||||
<Button
|
} catch (error) {
|
||||||
variant="ghost"
|
console.error("Error creating connector:", error);
|
||||||
className="mb-6"
|
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
} finally {
|
||||||
>
|
setIsSubmitting(false);
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
}
|
||||||
Back to Connectors
|
};
|
||||||
</Button>
|
|
||||||
|
|
||||||
<motion.div
|
return (
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<div className="container mx-auto py-8 max-w-3xl">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<Button
|
||||||
transition={{ duration: 0.5 }}
|
variant="ghost"
|
||||||
>
|
className="mb-6"
|
||||||
<Card className="border-2 border-border">
|
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||||
<CardHeader>
|
>
|
||||||
<CardTitle className="text-2xl font-bold">Connect Tavily API</CardTitle>
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<CardDescription>
|
Back to Connectors
|
||||||
Integrate with Tavily API to enhance your search capabilities with AI-powered search results.
|
</Button>
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Alert className="mb-6 bg-muted">
|
|
||||||
<Info className="h-4 w-4" />
|
|
||||||
<AlertTitle>API Key Required</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
|
|
||||||
<a
|
|
||||||
href="https://tavily.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-medium underline underline-offset-4"
|
|
||||||
>
|
|
||||||
tavily.com
|
|
||||||
</a>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Form {...form}>
|
<motion.div
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
initial={{ opacity: 0, y: 20 }}
|
||||||
<FormField
|
animate={{ opacity: 1, y: 0 }}
|
||||||
control={form.control}
|
transition={{ duration: 0.5 }}
|
||||||
name="name"
|
>
|
||||||
render={({ field }) => (
|
<Card className="border-2 border-border">
|
||||||
<FormItem>
|
<CardHeader>
|
||||||
<FormLabel>Connector Name</FormLabel>
|
<CardTitle className="text-2xl font-bold">Connect Tavily API</CardTitle>
|
||||||
<FormControl>
|
<CardDescription>
|
||||||
<Input placeholder="My Tavily API Connector" {...field} />
|
Integrate with Tavily API to enhance your search capabilities with AI-powered search
|
||||||
</FormControl>
|
results.
|
||||||
<FormDescription>
|
</CardDescription>
|
||||||
A friendly name to identify this connector.
|
</CardHeader>
|
||||||
</FormDescription>
|
<CardContent>
|
||||||
<FormMessage />
|
<Alert className="mb-6 bg-muted">
|
||||||
</FormItem>
|
<Info className="h-4 w-4" />
|
||||||
)}
|
<AlertTitle>API Key Required</AlertTitle>
|
||||||
/>
|
<AlertDescription>
|
||||||
|
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
|
||||||
|
<a
|
||||||
|
href="https://tavily.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
tavily.com
|
||||||
|
</a>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
<FormField
|
<Form {...form}>
|
||||||
control={form.control}
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
name="api_key"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="name"
|
||||||
<FormLabel>Tavily API Key</FormLabel>
|
render={({ field }) => (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Input
|
<FormLabel>Connector Name</FormLabel>
|
||||||
type="password"
|
<FormControl>
|
||||||
placeholder="Enter your Tavily API key"
|
<Input placeholder="My Tavily API Connector" {...field} />
|
||||||
{...field}
|
</FormControl>
|
||||||
/>
|
<FormDescription>A friendly name to identify this connector.</FormDescription>
|
||||||
</FormControl>
|
<FormMessage />
|
||||||
<FormDescription>
|
</FormItem>
|
||||||
Your API key will be encrypted and stored securely.
|
)}
|
||||||
</FormDescription>
|
/>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<FormField
|
||||||
<Button
|
control={form.control}
|
||||||
type="submit"
|
name="api_key"
|
||||||
disabled={isSubmitting}
|
render={({ field }) => (
|
||||||
className="w-full sm:w-auto"
|
<FormItem>
|
||||||
>
|
<FormLabel>Tavily API Key</FormLabel>
|
||||||
{isSubmitting ? (
|
<FormControl>
|
||||||
<>
|
<Input type="password" placeholder="Enter your Tavily API key" {...field} />
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
</FormControl>
|
||||||
Connecting...
|
<FormDescription>
|
||||||
</>
|
Your API key will be encrypted and stored securely.
|
||||||
) : (
|
</FormDescription>
|
||||||
<>
|
<FormMessage />
|
||||||
<Check className="mr-2 h-4 w-4" />
|
</FormItem>
|
||||||
Connect Tavily API
|
)}
|
||||||
</>
|
/>
|
||||||
)}
|
|
||||||
</Button>
|
<div className="flex justify-end">
|
||||||
</div>
|
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
|
||||||
</form>
|
{isSubmitting ? (
|
||||||
</Form>
|
<>
|
||||||
</CardContent>
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
Connecting...
|
||||||
<h4 className="text-sm font-medium">What you get with Tavily API:</h4>
|
</>
|
||||||
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
) : (
|
||||||
<li>AI-powered search results tailored to your queries</li>
|
<>
|
||||||
<li>Real-time information from the web</li>
|
<Check className="mr-2 h-4 w-4" />
|
||||||
<li>Enhanced search capabilities for your projects</li>
|
Connect Tavily API
|
||||||
</ul>
|
</>
|
||||||
</CardFooter>
|
)}
|
||||||
</Card>
|
</Button>
|
||||||
</motion.div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
);
|
</Form>
|
||||||
}
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
|
||||||
|
<h4 className="text-sm font-medium">What you get with Tavily API:</h4>
|
||||||
|
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
|
||||||
|
<li>AI-powered search results tailored to your queries</li>
|
||||||
|
<li>Real-time information from the web</li>
|
||||||
|
<li>Enhanced search capabilities for your projects</li>
|
||||||
|
</ul>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { type Tag, TagInput } from "emblor";
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
|
||||||
import { Tag, TagInput } from "emblor";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Globe, Loader2 } from "lucide-react";
|
import { Globe, Loader2 } from "lucide-react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
// URL validation regex
|
// URL validation regex
|
||||||
const urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/;
|
const urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/;
|
||||||
|
|
||||||
export default function WebpageCrawler() {
|
export default function WebpageCrawler() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const search_space_id = params.search_space_id as string;
|
const search_space_id = params.search_space_id as string;
|
||||||
|
|
||||||
const [urlTags, setUrlTags] = useState<Tag[]>([]);
|
|
||||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Function to validate a URL
|
const [urlTags, setUrlTags] = useState<Tag[]>([]);
|
||||||
const isValidUrl = (url: string): boolean => {
|
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||||
return urlRegex.test(url);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
};
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Function to handle URL submission
|
// Function to validate a URL
|
||||||
const handleSubmit = async () => {
|
const isValidUrl = (url: string): boolean => {
|
||||||
// Validate that we have at least one URL
|
return urlRegex.test(url);
|
||||||
if (urlTags.length === 0) {
|
};
|
||||||
setError("Please add at least one URL");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate all URLs
|
// Function to handle URL submission
|
||||||
const invalidUrls = urlTags.filter(tag => !isValidUrl(tag.text));
|
const handleSubmit = async () => {
|
||||||
if (invalidUrls.length > 0) {
|
// Validate that we have at least one URL
|
||||||
setError(`Invalid URLs detected: ${invalidUrls.map(tag => tag.text).join(', ')}`);
|
if (urlTags.length === 0) {
|
||||||
return;
|
setError("Please add at least one URL");
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setError(null);
|
// Validate all URLs
|
||||||
setIsSubmitting(true);
|
const invalidUrls = urlTags.filter((tag) => !isValidUrl(tag.text));
|
||||||
|
if (invalidUrls.length > 0) {
|
||||||
|
setError(`Invalid URLs detected: ${invalidUrls.map((tag) => tag.text).join(", ")}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
setError(null);
|
||||||
toast("URL Crawling", {
|
setIsSubmitting(true);
|
||||||
description: "Starting URL crawling process...",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract URLs from tags
|
try {
|
||||||
const urls = urlTags.map(tag => tag.text);
|
toast("URL Crawling", {
|
||||||
|
description: "Starting URL crawling process...",
|
||||||
|
});
|
||||||
|
|
||||||
// Make API call to backend
|
// Extract URLs from tags
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/`, {
|
const urls = urlTags.map((tag) => tag.text);
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem("surfsense_bearer_token")}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
"document_type": "CRAWLED_URL",
|
|
||||||
"content": urls,
|
|
||||||
"search_space_id": parseInt(search_space_id)
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
// Make API call to backend
|
||||||
throw new Error("Failed to crawl URLs");
|
const response = await fetch(
|
||||||
}
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
document_type: "CRAWLED_URL",
|
||||||
|
content: urls,
|
||||||
|
search_space_id: parseInt(search_space_id),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
await response.json();
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to crawl URLs");
|
||||||
|
}
|
||||||
|
|
||||||
toast("Crawling Successful", {
|
await response.json();
|
||||||
description: "URLs have been submitted for crawling",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Redirect to documents page
|
toast("Crawling Successful", {
|
||||||
router.push(`/dashboard/${search_space_id}/documents`);
|
description: "URLs have been submitted for crawling",
|
||||||
} catch (error: any) {
|
});
|
||||||
setError(error.message || "An error occurred while crawling URLs");
|
|
||||||
toast("Crawling Error", {
|
|
||||||
description: `Error crawling URLs: ${error.message}`,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to add a new URL tag
|
// Redirect to documents page
|
||||||
const handleAddTag = (text: string) => {
|
router.push(`/dashboard/${search_space_id}/documents`);
|
||||||
// Basic URL validation
|
} catch (error: any) {
|
||||||
if (!isValidUrl(text)) {
|
setError(error.message || "An error occurred while crawling URLs");
|
||||||
toast("Invalid URL", {
|
toast("Crawling Error", {
|
||||||
description: "Please enter a valid URL",
|
description: `Error crawling URLs: ${error.message}`,
|
||||||
});
|
});
|
||||||
return;
|
} finally {
|
||||||
}
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Check for duplicates
|
// Function to add a new URL tag
|
||||||
if (urlTags.some(tag => tag.text === text)) {
|
const handleAddTag = (text: string) => {
|
||||||
toast("Duplicate URL", {
|
// Basic URL validation
|
||||||
description: "This URL has already been added",
|
if (!isValidUrl(text)) {
|
||||||
});
|
toast("Invalid URL", {
|
||||||
return;
|
description: "Please enter a valid URL",
|
||||||
}
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Add the new tag
|
// Check for duplicates
|
||||||
const newTag: Tag = {
|
if (urlTags.some((tag) => tag.text === text)) {
|
||||||
id: Date.now().toString(),
|
toast("Duplicate URL", {
|
||||||
text: text,
|
description: "This URL has already been added",
|
||||||
};
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setUrlTags([...urlTags, newTag]);
|
// Add the new tag
|
||||||
};
|
const newTag: Tag = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
text: text,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
setUrlTags([...urlTags, newTag]);
|
||||||
<div className="container mx-auto py-8">
|
};
|
||||||
<Card className="max-w-2xl mx-auto">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Globe className="h-5 w-5" />
|
|
||||||
Add Webpages for Crawling
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Enter URLs to crawl and add to your document collection
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="url-input">Enter URLs to crawl</Label>
|
|
||||||
<TagInput
|
|
||||||
id="url-input"
|
|
||||||
tags={urlTags}
|
|
||||||
setTags={setUrlTags}
|
|
||||||
placeholder="Enter a URL and press Enter"
|
|
||||||
onAddTag={handleAddTag}
|
|
||||||
styleClasses={{
|
|
||||||
inlineTagsContainer:
|
|
||||||
"border-input rounded-lg bg-background shadow-sm shadow-black/5 transition-shadow focus-within:border-ring focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 p-1 gap-1",
|
|
||||||
input: "w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7",
|
|
||||||
tag: {
|
|
||||||
body: "h-7 relative bg-background border border-input hover:bg-background rounded-md font-medium text-xs ps-2 pe-7 flex",
|
|
||||||
closeButton:
|
|
||||||
"absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-foreground",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
activeTagIndex={activeTagIndex}
|
|
||||||
setActiveTagIndex={setActiveTagIndex}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Add multiple URLs by pressing Enter after each one
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
return (
|
||||||
<div className="text-sm text-red-500 mt-2">
|
<div className="container mx-auto py-8">
|
||||||
{error}
|
<Card className="max-w-2xl mx-auto">
|
||||||
</div>
|
<CardHeader>
|
||||||
)}
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Globe className="h-5 w-5" />
|
||||||
|
Add Webpages for Crawling
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Enter URLs to crawl and add to your document collection</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="url-input">Enter URLs to crawl</Label>
|
||||||
|
<TagInput
|
||||||
|
id="url-input"
|
||||||
|
tags={urlTags}
|
||||||
|
setTags={setUrlTags}
|
||||||
|
placeholder="Enter a URL and press Enter"
|
||||||
|
onAddTag={handleAddTag}
|
||||||
|
styleClasses={{
|
||||||
|
inlineTagsContainer:
|
||||||
|
"border-input rounded-lg bg-background shadow-sm shadow-black/5 transition-shadow focus-within:border-ring focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 p-1 gap-1",
|
||||||
|
input: "w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7",
|
||||||
|
tag: {
|
||||||
|
body: "h-7 relative bg-background border border-input hover:bg-background rounded-md font-medium text-xs ps-2 pe-7 flex",
|
||||||
|
closeButton:
|
||||||
|
"absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
activeTagIndex={activeTagIndex}
|
||||||
|
setActiveTagIndex={setActiveTagIndex}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Add multiple URLs by pressing Enter after each one
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-muted/50 rounded-lg p-4 text-sm">
|
{error && <div className="text-sm text-red-500 mt-2">{error}</div>}
|
||||||
<h4 className="font-medium mb-2">Tips for URL crawling:</h4>
|
|
||||||
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
|
<div className="bg-muted/50 rounded-lg p-4 text-sm">
|
||||||
<li>Enter complete URLs including http:// or https://</li>
|
<h4 className="font-medium mb-2">Tips for URL crawling:</h4>
|
||||||
<li>Make sure the websites allow crawling</li>
|
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
|
||||||
<li>Public webpages work best</li>
|
<li>Enter complete URLs including http:// or https://</li>
|
||||||
<li>Crawling may take some time depending on the website size</li>
|
<li>Make sure the websites allow crawling</li>
|
||||||
</ul>
|
<li>Public webpages work best</li>
|
||||||
</div>
|
<li>Crawling may take some time depending on the website size</li>
|
||||||
</div>
|
</ul>
|
||||||
</CardContent>
|
</div>
|
||||||
<CardFooter className="flex justify-between">
|
</div>
|
||||||
<Button
|
</CardContent>
|
||||||
variant="outline"
|
<CardFooter className="flex justify-between">
|
||||||
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
|
<Button
|
||||||
>
|
variant="outline"
|
||||||
Cancel
|
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
|
||||||
</Button>
|
>
|
||||||
<Button
|
Cancel
|
||||||
onClick={handleSubmit}
|
</Button>
|
||||||
disabled={isSubmitting || urlTags.length === 0}
|
<Button onClick={handleSubmit} disabled={isSubmitting || urlTags.length === 0}>
|
||||||
>
|
{isSubmitting ? (
|
||||||
{isSubmitting ? (
|
<>
|
||||||
<>
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
Submitting...
|
||||||
Submitting...
|
</>
|
||||||
</>
|
) : (
|
||||||
) : (
|
"Submit URLs for Crawling"
|
||||||
'Submit URLs for Crawling'
|
)}
|
||||||
)}
|
</Button>
|
||||||
</Button>
|
</CardFooter>
|
||||||
</CardFooter>
|
</Card>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,302 +1,304 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { IconBrandYoutube } from "@tabler/icons-react";
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { type Tag, TagInput } from "emblor";
|
||||||
import { Tag, TagInput } from "emblor";
|
import { motion, type Variants } from "framer-motion";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Loader2 } from "lucide-react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Youtube, Loader2 } from "lucide-react";
|
import { Button } from "@/components/ui/button";
|
||||||
import { motion } from "framer-motion";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
// YouTube video ID validation regex
|
// YouTube video ID validation regex
|
||||||
const youtubeRegex = /^(https:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})$/;
|
const youtubeRegex =
|
||||||
|
/^(https:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})$/;
|
||||||
|
|
||||||
export default function YouTubeVideoAdder() {
|
export default function YouTubeVideoAdder() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const search_space_id = params.search_space_id as string;
|
const search_space_id = params.search_space_id as string;
|
||||||
|
|
||||||
const [videoTags, setVideoTags] = useState<Tag[]>([]);
|
|
||||||
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Function to validate a YouTube URL
|
const [videoTags, setVideoTags] = useState<Tag[]>([]);
|
||||||
const isValidYoutubeUrl = (url: string): boolean => {
|
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
|
||||||
return youtubeRegex.test(url);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
};
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Function to extract video ID from URL
|
// Function to validate a YouTube URL
|
||||||
const extractVideoId = (url: string): string | null => {
|
const isValidYoutubeUrl = (url: string): boolean => {
|
||||||
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
|
return youtubeRegex.test(url);
|
||||||
return match ? match[1] : null;
|
};
|
||||||
};
|
|
||||||
|
|
||||||
// Function to handle video URL submission
|
// Function to extract video ID from URL
|
||||||
const handleSubmit = async () => {
|
const extractVideoId = (url: string): string | null => {
|
||||||
// Validate that we have at least one video URL
|
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
|
||||||
if (videoTags.length === 0) {
|
return match ? match[1] : null;
|
||||||
setError("Please add at least one YouTube video URL");
|
};
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate all URLs
|
// Function to handle video URL submission
|
||||||
const invalidUrls = videoTags.filter(tag => !isValidYoutubeUrl(tag.text));
|
const handleSubmit = async () => {
|
||||||
if (invalidUrls.length > 0) {
|
// Validate that we have at least one video URL
|
||||||
setError(`Invalid YouTube URLs detected: ${invalidUrls.map(tag => tag.text).join(', ')}`);
|
if (videoTags.length === 0) {
|
||||||
return;
|
setError("Please add at least one YouTube video URL");
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setError(null);
|
// Validate all URLs
|
||||||
setIsSubmitting(true);
|
const invalidUrls = videoTags.filter((tag) => !isValidYoutubeUrl(tag.text));
|
||||||
|
if (invalidUrls.length > 0) {
|
||||||
|
setError(`Invalid YouTube URLs detected: ${invalidUrls.map((tag) => tag.text).join(", ")}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
setError(null);
|
||||||
toast("YouTube Video Processing", {
|
setIsSubmitting(true);
|
||||||
description: "Starting YouTube video processing...",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract URLs from tags
|
try {
|
||||||
const videoUrls = videoTags.map(tag => tag.text);
|
toast("YouTube Video Processing", {
|
||||||
|
description: "Starting YouTube video processing...",
|
||||||
|
});
|
||||||
|
|
||||||
// Make API call to backend
|
// Extract URLs from tags
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/`, {
|
const videoUrls = videoTags.map((tag) => tag.text);
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem("surfsense_bearer_token")}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
"document_type": "YOUTUBE_VIDEO",
|
|
||||||
"content": videoUrls,
|
|
||||||
"search_space_id": parseInt(search_space_id)
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
// Make API call to backend
|
||||||
throw new Error("Failed to process YouTube videos");
|
const response = await fetch(
|
||||||
}
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
document_type: "YOUTUBE_VIDEO",
|
||||||
|
content: videoUrls,
|
||||||
|
search_space_id: parseInt(search_space_id),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
await response.json();
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to process YouTube videos");
|
||||||
|
}
|
||||||
|
|
||||||
toast("Processing Successful", {
|
await response.json();
|
||||||
description: "YouTube videos have been submitted for processing",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Redirect to documents page
|
toast("Processing Successful", {
|
||||||
router.push(`/dashboard/${search_space_id}/documents`);
|
description: "YouTube videos have been submitted for processing",
|
||||||
} catch (error: any) {
|
});
|
||||||
setError(error.message || "An error occurred while processing YouTube videos");
|
|
||||||
toast("Processing Error", {
|
|
||||||
description: `Error processing YouTube videos: ${error.message}`,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to add a new video URL tag
|
// Redirect to documents page
|
||||||
const handleAddTag = (text: string) => {
|
router.push(`/dashboard/${search_space_id}/documents`);
|
||||||
// Basic URL validation
|
} catch (error: any) {
|
||||||
if (!isValidYoutubeUrl(text)) {
|
setError(error.message || "An error occurred while processing YouTube videos");
|
||||||
toast("Invalid YouTube URL", {
|
toast("Processing Error", {
|
||||||
description: "Please enter a valid YouTube video URL",
|
description: `Error processing YouTube videos: ${error.message}`,
|
||||||
});
|
});
|
||||||
return;
|
} finally {
|
||||||
}
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Check for duplicates
|
// Function to add a new video URL tag
|
||||||
if (videoTags.some(tag => tag.text === text)) {
|
const handleAddTag = (text: string) => {
|
||||||
toast("Duplicate URL", {
|
// Basic URL validation
|
||||||
description: "This YouTube video has already been added",
|
if (!isValidYoutubeUrl(text)) {
|
||||||
});
|
toast("Invalid YouTube URL", {
|
||||||
return;
|
description: "Please enter a valid YouTube video URL",
|
||||||
}
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Add the new tag
|
// Check for duplicates
|
||||||
const newTag: Tag = {
|
if (videoTags.some((tag) => tag.text === text)) {
|
||||||
id: Date.now().toString(),
|
toast("Duplicate URL", {
|
||||||
text: text,
|
description: "This YouTube video has already been added",
|
||||||
};
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setVideoTags([...videoTags, newTag]);
|
// Add the new tag
|
||||||
};
|
const newTag: Tag = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
text: text,
|
||||||
|
};
|
||||||
|
|
||||||
// Animation variants
|
setVideoTags([...videoTags, newTag]);
|
||||||
const containerVariants = {
|
};
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemVariants = {
|
|
||||||
hidden: { y: 20, opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
y: 0,
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 300,
|
|
||||||
damping: 24
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
// Animation variants
|
||||||
<div className="container mx-auto py-8">
|
const containerVariants: Variants = {
|
||||||
<motion.div
|
hidden: { opacity: 0 },
|
||||||
initial="hidden"
|
visible: {
|
||||||
animate="visible"
|
opacity: 1,
|
||||||
variants={containerVariants}
|
transition: {
|
||||||
>
|
staggerChildren: 0.1,
|
||||||
<Card className="max-w-2xl mx-auto">
|
},
|
||||||
<motion.div variants={itemVariants}>
|
},
|
||||||
<CardHeader>
|
};
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Youtube className="h-5 w-5" />
|
|
||||||
Add YouTube Videos
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Enter YouTube video URLs to add to your document collection
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div variants={itemVariants}>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="video-input">Enter YouTube Video URLs</Label>
|
|
||||||
<TagInput
|
|
||||||
id="video-input"
|
|
||||||
tags={videoTags}
|
|
||||||
setTags={setVideoTags}
|
|
||||||
placeholder="Enter a YouTube URL and press Enter"
|
|
||||||
onAddTag={handleAddTag}
|
|
||||||
styleClasses={{
|
|
||||||
inlineTagsContainer:
|
|
||||||
"border-input rounded-lg bg-background shadow-sm shadow-black/5 transition-shadow focus-within:border-ring focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 p-1 gap-1",
|
|
||||||
input: "w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7",
|
|
||||||
tag: {
|
|
||||||
body: "h-7 relative bg-background border border-input hover:bg-background rounded-md font-medium text-xs ps-2 pe-7 flex",
|
|
||||||
closeButton:
|
|
||||||
"absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-foreground",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
activeTagIndex={activeTagIndex}
|
|
||||||
setActiveTagIndex={setActiveTagIndex}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Add multiple YouTube URLs by pressing Enter after each one
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
const itemVariants: Variants = {
|
||||||
<motion.div
|
hidden: { y: 20, opacity: 0 },
|
||||||
className="text-sm text-red-500 mt-2"
|
visible: {
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
y: 0,
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
opacity: 1,
|
||||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
transition: {
|
||||||
>
|
type: "spring",
|
||||||
{error}
|
stiffness: 300,
|
||||||
</motion.div>
|
damping: 24,
|
||||||
)}
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
<motion.div
|
return (
|
||||||
variants={itemVariants}
|
<div className="container mx-auto py-8">
|
||||||
className="bg-muted/50 rounded-lg p-4 text-sm"
|
<motion.div initial="hidden" animate="visible" variants={containerVariants}>
|
||||||
>
|
<Card className="max-w-2xl mx-auto">
|
||||||
<h4 className="font-medium mb-2">Tips for adding YouTube videos:</h4>
|
<motion.div variants={itemVariants}>
|
||||||
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
|
<CardHeader>
|
||||||
<li>Use standard YouTube URLs (youtube.com/watch?v= or youtu.be/)</li>
|
<CardTitle className="flex items-center gap-2">
|
||||||
<li>Make sure videos are publicly accessible</li>
|
<IconBrandYoutube className="h-5 w-5" />
|
||||||
<li>Supported formats: youtube.com/watch?v=VIDEO_ID or youtu.be/VIDEO_ID</li>
|
Add YouTube Videos
|
||||||
<li>Processing may take some time depending on video length</li>
|
</CardTitle>
|
||||||
</ul>
|
<CardDescription>
|
||||||
</motion.div>
|
Enter YouTube video URLs to add to your document collection
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{videoTags.length > 0 && (
|
<motion.div variants={itemVariants}>
|
||||||
<motion.div
|
<CardContent>
|
||||||
variants={itemVariants}
|
<div className="space-y-4">
|
||||||
className="mt-4 space-y-2"
|
<div className="space-y-2">
|
||||||
>
|
<Label htmlFor="video-input">Enter YouTube Video URLs</Label>
|
||||||
<h4 className="font-medium">Preview:</h4>
|
<TagInput
|
||||||
<div className="grid grid-cols-1 gap-3">
|
id="video-input"
|
||||||
{videoTags.map((tag, index) => {
|
tags={videoTags}
|
||||||
const videoId = extractVideoId(tag.text);
|
setTags={setVideoTags}
|
||||||
return videoId ? (
|
placeholder="Enter a YouTube URL and press Enter"
|
||||||
<motion.div
|
onAddTag={handleAddTag}
|
||||||
key={tag.id}
|
styleClasses={{
|
||||||
initial={{ opacity: 0, y: 10 }}
|
inlineTagsContainer:
|
||||||
animate={{ opacity: 1, y: 0 }}
|
"border-input rounded-lg bg-background shadow-sm shadow-black/5 transition-shadow focus-within:border-ring focus-within:outline-none focus-within:ring-[3px] focus-within:ring-ring/20 p-1 gap-1",
|
||||||
transition={{ delay: index * 0.1 }}
|
input: "w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7",
|
||||||
className="relative aspect-video rounded-lg overflow-hidden border"
|
tag: {
|
||||||
>
|
body: "h-7 relative bg-background border border-input hover:bg-background rounded-md font-medium text-xs ps-2 pe-7 flex",
|
||||||
<iframe
|
closeButton:
|
||||||
width="100%"
|
"absolute -inset-y-px -end-px p-0 rounded-e-lg flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-muted-foreground/80 hover:text-foreground",
|
||||||
height="100%"
|
},
|
||||||
src={`https://www.youtube.com/embed/${videoId}`}
|
}}
|
||||||
title="YouTube video player"
|
activeTagIndex={activeTagIndex}
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
setActiveTagIndex={setActiveTagIndex}
|
||||||
allowFullScreen
|
/>
|
||||||
></iframe>
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
</motion.div>
|
Add multiple YouTube URLs by pressing Enter after each one
|
||||||
) : null;
|
</p>
|
||||||
})}
|
</div>
|
||||||
</div>
|
|
||||||
</motion.div>
|
{error && (
|
||||||
)}
|
<motion.div
|
||||||
</div>
|
className="text-sm text-red-500 mt-2"
|
||||||
</CardContent>
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
</motion.div>
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||||
<motion.div variants={itemVariants}>
|
>
|
||||||
<CardFooter className="flex justify-between">
|
{error}
|
||||||
<Button
|
</motion.div>
|
||||||
variant="outline"
|
)}
|
||||||
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
|
|
||||||
>
|
<motion.div variants={itemVariants} className="bg-muted/50 rounded-lg p-4 text-sm">
|
||||||
Cancel
|
<h4 className="font-medium mb-2">Tips for adding YouTube videos:</h4>
|
||||||
</Button>
|
<ul className="list-disc pl-5 space-y-1 text-muted-foreground">
|
||||||
<Button
|
<li>Use standard YouTube URLs (youtube.com/watch?v= or youtu.be/)</li>
|
||||||
onClick={handleSubmit}
|
<li>Make sure videos are publicly accessible</li>
|
||||||
disabled={isSubmitting || videoTags.length === 0}
|
<li>Supported formats: youtube.com/watch?v=VIDEO_ID or youtu.be/VIDEO_ID</li>
|
||||||
className="relative overflow-hidden"
|
<li>Processing may take some time depending on video length</li>
|
||||||
>
|
</ul>
|
||||||
{isSubmitting ? (
|
</motion.div>
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
{videoTags.length > 0 && (
|
||||||
Processing...
|
<motion.div variants={itemVariants} className="mt-4 space-y-2">
|
||||||
</>
|
<h4 className="font-medium">Preview:</h4>
|
||||||
) : (
|
<div className="grid grid-cols-1 gap-3">
|
||||||
<>
|
{videoTags.map((tag, index) => {
|
||||||
<motion.span
|
const videoId = extractVideoId(tag.text);
|
||||||
initial={{ x: -5, opacity: 0 }}
|
return videoId ? (
|
||||||
animate={{ x: 0, opacity: 1 }}
|
<motion.div
|
||||||
transition={{ delay: 0.2 }}
|
key={tag.id}
|
||||||
className="mr-2"
|
initial={{ opacity: 0, y: 10 }}
|
||||||
>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<Youtube className="h-4 w-4" />
|
transition={{ delay: index * 0.1 }}
|
||||||
</motion.span>
|
className="relative aspect-video rounded-lg overflow-hidden border"
|
||||||
Submit YouTube Videos
|
>
|
||||||
</>
|
<iframe
|
||||||
)}
|
width="100%"
|
||||||
<motion.div
|
height="100%"
|
||||||
className="absolute inset-0 bg-primary/10"
|
src={`https://www.youtube.com/embed/${videoId}`}
|
||||||
initial={{ x: "-100%" }}
|
title="YouTube video player"
|
||||||
animate={isSubmitting ? { x: "0%" } : { x: "-100%" }}
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
transition={{ duration: 0.5, ease: "easeInOut" }}
|
allowFullScreen
|
||||||
/>
|
></iframe>
|
||||||
</Button>
|
</motion.div>
|
||||||
</CardFooter>
|
) : null;
|
||||||
</motion.div>
|
})}
|
||||||
</Card>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
)}
|
||||||
);
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div variants={itemVariants}>
|
||||||
|
<CardFooter className="flex justify-between">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push(`/dashboard/${search_space_id}/documents`)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting || videoTags.length === 0}
|
||||||
|
className="relative overflow-hidden"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<motion.span
|
||||||
|
initial={{ x: -5, opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="mr-2"
|
||||||
|
>
|
||||||
|
<IconBrandYoutube className="h-4 w-4" />
|
||||||
|
</motion.span>
|
||||||
|
Submit YouTube Videos
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 bg-primary/10"
|
||||||
|
initial={{ x: "-100%" }}
|
||||||
|
animate={isSubmitting ? { x: "0%" } : { x: "-100%" }}
|
||||||
|
transition={{ duration: 0.5, ease: "easeInOut" }}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</motion.div>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,100 +1,99 @@
|
||||||
// Server component
|
// Server component
|
||||||
import React, { use } from 'react'
|
import type React from "react";
|
||||||
import { DashboardClientLayout } from './client-layout'
|
import { use } from "react";
|
||||||
|
import { DashboardClientLayout } from "./client-layout";
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
params,
|
params,
|
||||||
children
|
children,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ search_space_id: string }>,
|
params: Promise<{ search_space_id: string }>;
|
||||||
children: React.ReactNode
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
// Use React.use to unwrap the params Promise
|
// Use React.use to unwrap the params Promise
|
||||||
const { search_space_id } = use(params);
|
const { search_space_id } = use(params);
|
||||||
|
|
||||||
const customNavSecondary = [
|
const customNavSecondary = [
|
||||||
{
|
{
|
||||||
title: `All Search Spaces`,
|
title: `All Search Spaces`,
|
||||||
url: `#`,
|
url: `#`,
|
||||||
icon: "Info",
|
icon: "Info",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: `All Search Spaces`,
|
title: `All Search Spaces`,
|
||||||
url: "/dashboard",
|
url: "/dashboard",
|
||||||
icon: "Undo2",
|
icon: "Undo2",
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const customNavMain = [
|
const customNavMain = [
|
||||||
{
|
{
|
||||||
title: "Researcher",
|
title: "Researcher",
|
||||||
url: `/dashboard/${search_space_id}/researcher`,
|
url: `/dashboard/${search_space_id}/researcher`,
|
||||||
icon: "SquareTerminal",
|
icon: "SquareTerminal",
|
||||||
isActive: true,
|
isActive: true,
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
title: "Documents",
|
title: "Documents",
|
||||||
url: "#",
|
url: "#",
|
||||||
icon: "FileStack",
|
icon: "FileStack",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "Upload Documents",
|
title: "Upload Documents",
|
||||||
url: `/dashboard/${search_space_id}/documents/upload`,
|
url: `/dashboard/${search_space_id}/documents/upload`,
|
||||||
},
|
},
|
||||||
// { TODO: FIX THIS AND ADD IT BACK
|
// { TODO: FIX THIS AND ADD IT BACK
|
||||||
// title: "Add Webpages",
|
// title: "Add Webpages",
|
||||||
// url: `/dashboard/${search_space_id}/documents/webpage`,
|
// url: `/dashboard/${search_space_id}/documents/webpage`,
|
||||||
// },
|
// },
|
||||||
{
|
{
|
||||||
title: "Add Youtube Videos",
|
title: "Add Youtube Videos",
|
||||||
url: `/dashboard/${search_space_id}/documents/youtube`,
|
url: `/dashboard/${search_space_id}/documents/youtube`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Manage Documents",
|
title: "Manage Documents",
|
||||||
url: `/dashboard/${search_space_id}/documents`,
|
url: `/dashboard/${search_space_id}/documents`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Connectors",
|
title: "Connectors",
|
||||||
url: `#`,
|
url: `#`,
|
||||||
icon: "Cable",
|
icon: "Cable",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "Add Connector",
|
title: "Add Connector",
|
||||||
url: `/dashboard/${search_space_id}/connectors/add`,
|
url: `/dashboard/${search_space_id}/connectors/add`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Manage Connectors",
|
title: "Manage Connectors",
|
||||||
url: `/dashboard/${search_space_id}/connectors`,
|
url: `/dashboard/${search_space_id}/connectors`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Podcasts",
|
title: "Podcasts",
|
||||||
url: `/dashboard/${search_space_id}/podcasts`,
|
url: `/dashboard/${search_space_id}/podcasts`,
|
||||||
icon: "Podcast",
|
icon: "Podcast",
|
||||||
items: [
|
items: [],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
title: "Logs",
|
||||||
title: "Logs",
|
url: `/dashboard/${search_space_id}/logs`,
|
||||||
url: `/dashboard/${search_space_id}/logs`,
|
icon: "FileText",
|
||||||
icon: "FileText",
|
items: [],
|
||||||
items: [
|
},
|
||||||
],
|
];
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardClientLayout
|
<DashboardClientLayout
|
||||||
searchSpaceId={search_space_id}
|
searchSpaceId={search_space_id}
|
||||||
navSecondary={customNavSecondary}
|
navSecondary={customNavSecondary}
|
||||||
navMain={customNavMain}
|
navMain={customNavMain}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</DashboardClientLayout>
|
</DashboardClientLayout>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,20 +1,24 @@
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from "react";
|
||||||
import PodcastsPageClient from './podcasts-client';
|
import PodcastsPageClient from "./podcasts-client";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: {
|
params: {
|
||||||
search_space_id: string;
|
search_space_id: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function PodcastsPage({ params }: PageProps) {
|
export default async function PodcastsPage({ params }: PageProps) {
|
||||||
const { search_space_id: searchSpaceId } = await Promise.resolve(params);
|
const { search_space_id: searchSpaceId } = await Promise.resolve(params);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="flex items-center justify-center h-[60vh]">
|
<Suspense
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
fallback={
|
||||||
</div>}>
|
<div className="flex items-center justify-center h-[60vh]">
|
||||||
<PodcastsPageClient searchSpaceId={searchSpaceId} />
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||||
</Suspense>
|
</div>
|
||||||
);
|
}
|
||||||
|
>
|
||||||
|
<PodcastsPageClient searchSpaceId={searchSpaceId} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion, type Variants } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
|
@ -16,8 +16,9 @@ import {
|
||||||
VolumeX,
|
VolumeX,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
// UI Components
|
// UI Components
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
@ -45,7 +46,6 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface PodcastItem {
|
interface PodcastItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -60,7 +60,7 @@ interface PodcastsPageClientProps {
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageVariants = {
|
const pageVariants: Variants = {
|
||||||
initial: { opacity: 0 },
|
initial: { opacity: 0 },
|
||||||
enter: {
|
enter: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
|
|
@ -69,7 +69,7 @@ const pageVariants = {
|
||||||
exit: { opacity: 0, transition: { duration: 0.3, ease: "easeInOut" } },
|
exit: { opacity: 0, transition: { duration: 0.3, ease: "easeInOut" } },
|
||||||
};
|
};
|
||||||
|
|
||||||
const podcastCardVariants = {
|
const podcastCardVariants: Variants = {
|
||||||
initial: { scale: 0.95, y: 20, opacity: 0 },
|
initial: { scale: 0.95, y: 20, opacity: 0 },
|
||||||
animate: {
|
animate: {
|
||||||
scale: 1,
|
scale: 1,
|
||||||
|
|
@ -83,9 +83,7 @@ const podcastCardVariants = {
|
||||||
|
|
||||||
const MotionCard = motion(Card);
|
const MotionCard = motion(Card);
|
||||||
|
|
||||||
export default function PodcastsPageClient({
|
export default function PodcastsPageClient({ searchSpaceId }: PodcastsPageClientProps) {
|
||||||
searchSpaceId,
|
|
||||||
}: PodcastsPageClientProps) {
|
|
||||||
const [podcasts, setPodcasts] = useState<PodcastItem[]>([]);
|
const [podcasts, setPodcasts] = useState<PodcastItem[]>([]);
|
||||||
const [filteredPodcasts, setFilteredPodcasts] = useState<PodcastItem[]>([]);
|
const [filteredPodcasts, setFilteredPodcasts] = useState<PodcastItem[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
@ -100,9 +98,7 @@ export default function PodcastsPageClient({
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
// Audio player state
|
// Audio player state
|
||||||
const [currentPodcast, setCurrentPodcast] = useState<PodcastItem | null>(
|
const [currentPodcast, setCurrentPodcast] = useState<PodcastItem | null>(null);
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [audioSrc, setAudioSrc] = useState<string | undefined>(undefined);
|
const [audioSrc, setAudioSrc] = useState<string | undefined>(undefined);
|
||||||
const [isAudioLoading, setIsAudioLoading] = useState(false);
|
const [isAudioLoading, setIsAudioLoading] = useState(false);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
|
@ -141,13 +137,13 @@ export default function PodcastsPageClient({
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => null);
|
const errorData = await response.json().catch(() => null);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to fetch podcasts: ${response.status} ${errorData?.detail || ""}`,
|
`Failed to fetch podcasts: ${response.status} ${errorData?.detail || ""}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,9 +153,7 @@ export default function PodcastsPageClient({
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching podcasts:", error);
|
console.error("Error fetching podcasts:", error);
|
||||||
setError(
|
setError(error instanceof Error ? error.message : "Unknown error occurred");
|
||||||
error instanceof Error ? error.message : "Unknown error occurred",
|
|
||||||
);
|
|
||||||
setPodcasts([]);
|
setPodcasts([]);
|
||||||
setFilteredPodcasts([]);
|
setFilteredPodcasts([]);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -168,7 +162,7 @@ export default function PodcastsPageClient({
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchPodcasts();
|
fetchPodcasts();
|
||||||
}, [searchSpaceId]);
|
}, []);
|
||||||
|
|
||||||
// Filter and sort podcasts based on search query and sort order
|
// Filter and sort podcasts based on search query and sort order
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -177,15 +171,11 @@ export default function PodcastsPageClient({
|
||||||
// Filter by search term
|
// Filter by search term
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
result = result.filter((podcast) =>
|
result = result.filter((podcast) => podcast.title.toLowerCase().includes(query));
|
||||||
podcast.title.toLowerCase().includes(query),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by search space
|
// Filter by search space
|
||||||
result = result.filter(
|
result = result.filter((podcast) => podcast.search_space_id === parseInt(searchSpaceId));
|
||||||
(podcast) => podcast.search_space_id === parseInt(searchSpaceId),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sort podcasts
|
// Sort podcasts
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
|
|
@ -294,7 +284,7 @@ export default function PodcastsPageClient({
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current.currentTime = Math.min(
|
audioRef.current.currentTime = Math.min(
|
||||||
audioRef.current.duration,
|
audioRef.current.duration,
|
||||||
audioRef.current.currentTime + 10,
|
audioRef.current.currentTime + 10
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -302,10 +292,7 @@ export default function PodcastsPageClient({
|
||||||
// Skip backward 10 seconds
|
// Skip backward 10 seconds
|
||||||
const skipBackward = () => {
|
const skipBackward = () => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current.currentTime = Math.max(
|
audioRef.current.currentTime = Math.max(0, audioRef.current.currentTime - 10);
|
||||||
0,
|
|
||||||
audioRef.current.currentTime - 10,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -361,13 +348,11 @@ export default function PodcastsPageClient({
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(`Failed to fetch audio stream: ${response.statusText}`);
|
||||||
`Failed to fetch audio stream: ${response.statusText}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
|
|
@ -389,11 +374,7 @@ export default function PodcastsPageClient({
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching or playing podcast:", error);
|
console.error("Error fetching or playing podcast:", error);
|
||||||
toast.error(
|
toast.error(error instanceof Error ? error.message : "Failed to load podcast audio.");
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to load podcast audio.",
|
|
||||||
);
|
|
||||||
// Reset state on error
|
// Reset state on error
|
||||||
setCurrentPodcast(null);
|
setCurrentPodcast(null);
|
||||||
setAudioSrc(undefined);
|
setAudioSrc(undefined);
|
||||||
|
|
@ -422,7 +403,7 @@ export default function PodcastsPageClient({
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -435,7 +416,7 @@ export default function PodcastsPageClient({
|
||||||
|
|
||||||
// Update local state by removing the deleted podcast
|
// Update local state by removing the deleted podcast
|
||||||
setPodcasts((prevPodcasts) =>
|
setPodcasts((prevPodcasts) =>
|
||||||
prevPodcasts.filter((podcast) => podcast.id !== podcastToDelete.id),
|
prevPodcasts.filter((podcast) => podcast.id !== podcastToDelete.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
// If the current playing podcast is deleted, stop playback
|
// If the current playing podcast is deleted, stop playback
|
||||||
|
|
@ -450,9 +431,7 @@ export default function PodcastsPageClient({
|
||||||
toast.success("Podcast deleted successfully");
|
toast.success("Podcast deleted successfully");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting podcast:", error);
|
console.error("Error deleting podcast:", error);
|
||||||
toast.error(
|
toast.error(error instanceof Error ? error.message : "Failed to delete podcast");
|
||||||
error instanceof Error ? error.message : "Failed to delete podcast",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -507,9 +486,7 @@ export default function PodcastsPageClient({
|
||||||
<div className="flex items-center justify-center h-40">
|
<div className="flex items-center justify-center h-40">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">Loading podcasts...</p>
|
||||||
Loading podcasts...
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -563,11 +540,13 @@ export default function PodcastsPageClient({
|
||||||
>
|
>
|
||||||
<div className="relative w-full aspect-[16/10] mb-4 rounded-lg overflow-hidden">
|
<div className="relative w-full aspect-[16/10] mb-4 rounded-lg overflow-hidden">
|
||||||
{/* Podcast image with gradient overlay */}
|
{/* Podcast image with gradient overlay */}
|
||||||
<img
|
<Image
|
||||||
src={PODCAST_IMAGE_URL}
|
src={PODCAST_IMAGE_URL}
|
||||||
alt="Podcast illustration"
|
alt="Podcast illustration"
|
||||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105 brightness-[0.85] contrast-[1.1]"
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105 brightness-[0.85] contrast-[1.1]"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
width={100}
|
||||||
|
height={100}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Better overlay with gradient for improved text legibility */}
|
{/* Better overlay with gradient for improved text legibility */}
|
||||||
|
|
@ -589,18 +568,13 @@ export default function PodcastsPageClient({
|
||||||
transition={{ type: "spring", damping: 20 }}
|
transition={{ type: "spring", damping: 20 }}
|
||||||
>
|
>
|
||||||
<div className="h-14 w-14 rounded-full border-4 border-primary/30 border-t-primary animate-spin"></div>
|
<div className="h-14 w-14 rounded-full border-4 border-primary/30 border-t-primary animate-spin"></div>
|
||||||
<p className="text-sm text-foreground font-medium">
|
<p className="text-sm text-foreground font-medium">Loading podcast...</p>
|
||||||
Loading podcast...
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Play button with animations */}
|
{/* Play button with animations */}
|
||||||
{!(
|
{!(currentPodcast?.id === podcast.id && (isPlaying || isAudioLoading)) && (
|
||||||
currentPodcast?.id === podcast.id &&
|
|
||||||
(isPlaying || isAudioLoading)
|
|
||||||
) && (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
|
||||||
whileHover={{ scale: 1.1 }}
|
whileHover={{ scale: 1.1 }}
|
||||||
|
|
@ -636,42 +610,40 @@ export default function PodcastsPageClient({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pause button with animations */}
|
{/* Pause button with animations */}
|
||||||
{currentPodcast?.id === podcast.id &&
|
{currentPodcast?.id === podcast.id && isPlaying && !isAudioLoading && (
|
||||||
isPlaying &&
|
<motion.div
|
||||||
!isAudioLoading && (
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
|
||||||
<motion.div
|
whileHover={{ scale: 1.1 }}
|
||||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-10"
|
whileTap={{ scale: 0.9 }}
|
||||||
whileHover={{ scale: 1.1 }}
|
>
|
||||||
whileTap={{ scale: 0.9 }}
|
<Button
|
||||||
>
|
variant="secondary"
|
||||||
<Button
|
size="icon"
|
||||||
variant="secondary"
|
className="h-16 w-16 rounded-full
|
||||||
size="icon"
|
|
||||||
className="h-16 w-16 rounded-full
|
|
||||||
bg-background/80 hover:bg-background/95 backdrop-blur-md
|
bg-background/80 hover:bg-background/95 backdrop-blur-md
|
||||||
transition-all duration-200 shadow-xl border-0
|
transition-all duration-200 shadow-xl border-0
|
||||||
flex items-center justify-center"
|
flex items-center justify-center"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
togglePlayPause();
|
togglePlayPause();
|
||||||
|
}}
|
||||||
|
disabled={isAudioLoading}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 400,
|
||||||
|
damping: 10,
|
||||||
}}
|
}}
|
||||||
disabled={isAudioLoading}
|
className="text-primary w-10 h-10 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<motion.div
|
<Pause className="h-8 w-8" />
|
||||||
initial={{ scale: 0.8 }}
|
</motion.div>
|
||||||
animate={{ scale: 1 }}
|
</Button>
|
||||||
transition={{
|
</motion.div>
|
||||||
type: "spring",
|
)}
|
||||||
stiffness: 400,
|
|
||||||
damping: 10,
|
|
||||||
}}
|
|
||||||
className="text-primary w-10 h-10 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Pause className="h-8 w-8" />
|
|
||||||
</motion.div>
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Now playing indicator */}
|
{/* Now playing indicator */}
|
||||||
{currentPodcast?.id === podcast.id && !isAudioLoading && (
|
{currentPodcast?.id === podcast.id && !isAudioLoading && (
|
||||||
|
|
@ -705,7 +677,8 @@ export default function PodcastsPageClient({
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.1 }}
|
transition={{ delay: 0.1 }}
|
||||||
>
|
>
|
||||||
<div
|
<Button
|
||||||
|
variant="ghost"
|
||||||
className="h-1.5 bg-muted rounded-full cursor-pointer group relative overflow-hidden"
|
className="h-1.5 bg-muted rounded-full cursor-pointer group relative overflow-hidden"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -713,10 +686,7 @@ export default function PodcastsPageClient({
|
||||||
const container = e.currentTarget;
|
const container = e.currentTarget;
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
const x = e.clientX - rect.left;
|
const x = e.clientX - rect.left;
|
||||||
const percentage = Math.max(
|
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
||||||
0,
|
|
||||||
Math.min(1, x / rect.width),
|
|
||||||
);
|
|
||||||
const newTime = percentage * duration;
|
const newTime = percentage * duration;
|
||||||
handleSeek([newTime]);
|
handleSeek([newTime]);
|
||||||
}}
|
}}
|
||||||
|
|
@ -735,7 +705,7 @@ export default function PodcastsPageClient({
|
||||||
whileHover={{ scale: 1.5 }}
|
whileHover={{ scale: 1.5 }}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</Button>
|
||||||
<div className="flex justify-between mt-1.5 text-xs text-muted-foreground">
|
<div className="flex justify-between mt-1.5 text-xs text-muted-foreground">
|
||||||
<span>{formatTime(currentTime)}</span>
|
<span>{formatTime(currentTime)}</span>
|
||||||
<span>{formatTime(duration)}</span>
|
<span>{formatTime(duration)}</span>
|
||||||
|
|
@ -750,10 +720,7 @@ export default function PodcastsPageClient({
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.2 }}
|
transition={{ delay: 0.2 }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
|
||||||
whileHover={{ scale: 1.2 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -768,10 +735,7 @@ export default function PodcastsPageClient({
|
||||||
<SkipBack className="w-5 h-5" />
|
<SkipBack className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<motion.div
|
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
|
||||||
whileHover={{ scale: 1.2 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -789,10 +753,7 @@ export default function PodcastsPageClient({
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<motion.div
|
<motion.div whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.95 }}>
|
||||||
whileHover={{ scale: 1.2 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -872,9 +833,7 @@ export default function PodcastsPageClient({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-grow min-w-0">
|
<div className="flex-grow min-w-0">
|
||||||
<h4 className="font-medium text-sm line-clamp-1">
|
<h4 className="font-medium text-sm line-clamp-1">{currentPodcast.title}</h4>
|
||||||
{currentPodcast.title}
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<div className="flex-grow relative">
|
<div className="flex-grow relative">
|
||||||
|
|
@ -901,24 +860,13 @@ export default function PodcastsPageClient({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<motion.div
|
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||||
whileHover={{ scale: 1.1 }}
|
<Button variant="ghost" size="icon" onClick={skipBackward} className="h-8 w-8">
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={skipBackward}
|
|
||||||
className="h-8 w-8"
|
|
||||||
>
|
|
||||||
<SkipBack className="h-4 w-4" />
|
<SkipBack className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -933,25 +881,14 @@ export default function PodcastsPageClient({
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||||
whileHover={{ scale: 1.1 }}
|
<Button variant="ghost" size="icon" onClick={skipForward} className="h-8 w-8">
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={skipForward}
|
|
||||||
className="h-8 w-8"
|
|
||||||
>
|
|
||||||
<SkipForward className="h-4 w-4" />
|
<SkipForward className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="hidden md:flex items-center gap-2 ml-4 w-32">
|
<div className="hidden md:flex items-center gap-2 ml-4 w-32">
|
||||||
<motion.div
|
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -984,10 +921,7 @@ export default function PodcastsPageClient({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}>
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -1014,8 +948,8 @@ export default function PodcastsPageClient({
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete{" "}
|
Are you sure you want to delete{" "}
|
||||||
<span className="font-medium">{podcastToDelete?.title}</span>?
|
<span className="font-medium">{podcastToDelete?.title}</span>? This action cannot be
|
||||||
This action cannot be undone.
|
undone.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||||
|
|
@ -1086,17 +1020,16 @@ export default function PodcastsPageClient({
|
||||||
console.error("Audio error code:", audioRef.current.error.code);
|
console.error("Audio error code:", audioRef.current.error.code);
|
||||||
|
|
||||||
// Don't show error message for aborted loads
|
// Don't show error message for aborted loads
|
||||||
if (
|
if (audioRef.current.error.code !== audioRef.current.error.MEDIA_ERR_ABORTED) {
|
||||||
audioRef.current.error.code !==
|
|
||||||
audioRef.current.error.MEDIA_ERR_ABORTED
|
|
||||||
) {
|
|
||||||
toast.error("Error playing audio. Please try again.");
|
toast.error("Error playing audio. Please try again.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Reset playing state on error
|
// Reset playing state on error
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</audio>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,19 +58,12 @@ export default function ResearcherPage() {
|
||||||
const getChatStateStorageKey = (searchSpaceId: string, chatId: string) =>
|
const getChatStateStorageKey = (searchSpaceId: string, chatId: string) =>
|
||||||
`surfsense_chat_state_${searchSpaceId}_${chatId}`;
|
`surfsense_chat_state_${searchSpaceId}_${chatId}`;
|
||||||
|
|
||||||
const storeChatState = (
|
const storeChatState = (searchSpaceId: string, chatId: string, state: ChatState) => {
|
||||||
searchSpaceId: string,
|
|
||||||
chatId: string,
|
|
||||||
state: ChatState,
|
|
||||||
) => {
|
|
||||||
const key = getChatStateStorageKey(searchSpaceId, chatId);
|
const key = getChatStateStorageKey(searchSpaceId, chatId);
|
||||||
localStorage.setItem(key, JSON.stringify(state));
|
localStorage.setItem(key, JSON.stringify(state));
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreChatState = (
|
const restoreChatState = (searchSpaceId: string, chatId: string): ChatState | null => {
|
||||||
searchSpaceId: string,
|
|
||||||
chatId: string,
|
|
||||||
): ChatState | null => {
|
|
||||||
const key = getChatStateStorageKey(searchSpaceId, chatId);
|
const key = getChatStateStorageKey(searchSpaceId, chatId);
|
||||||
const stored = localStorage.getItem(key);
|
const stored = localStorage.getItem(key);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
|
|
@ -108,13 +101,9 @@ export default function ResearcherPage() {
|
||||||
|
|
||||||
const customHandlerAppend = async (
|
const customHandlerAppend = async (
|
||||||
message: Message | CreateMessage,
|
message: Message | CreateMessage,
|
||||||
chatRequestOptions?: { data?: any },
|
chatRequestOptions?: { data?: any }
|
||||||
) => {
|
) => {
|
||||||
const newChatId = await createChat(
|
const newChatId = await createChat(message.content, researchMode, selectedConnectors);
|
||||||
message.content,
|
|
||||||
researchMode,
|
|
||||||
selectedConnectors,
|
|
||||||
);
|
|
||||||
if (newChatId) {
|
if (newChatId) {
|
||||||
// Store chat state before navigation
|
// Store chat state before navigation
|
||||||
storeChatState(search_space_id as string, newChatId, {
|
storeChatState(search_space_id as string, newChatId, {
|
||||||
|
|
@ -138,10 +127,7 @@ export default function ResearcherPage() {
|
||||||
// Restore chat state from localStorage on page load
|
// Restore chat state from localStorage on page load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chatIdParam && search_space_id) {
|
if (chatIdParam && search_space_id) {
|
||||||
const restoredState = restoreChatState(
|
const restoredState = restoreChatState(search_space_id as string, chatIdParam);
|
||||||
search_space_id as string,
|
|
||||||
chatIdParam,
|
|
||||||
);
|
|
||||||
if (restoredState) {
|
if (restoredState) {
|
||||||
setSelectedDocuments(restoredState.selectedDocuments);
|
setSelectedDocuments(restoredState.selectedDocuments);
|
||||||
setSelectedConnectors(restoredState.selectedConnectors);
|
setSelectedConnectors(restoredState.selectedConnectors);
|
||||||
|
|
@ -168,19 +154,13 @@ export default function ResearcherPage() {
|
||||||
setResearchMode(chatData.type as ResearchMode);
|
setResearchMode(chatData.type as ResearchMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (chatData.initial_connectors && Array.isArray(chatData.initial_connectors)) {
|
||||||
chatData.initial_connectors &&
|
|
||||||
Array.isArray(chatData.initial_connectors)
|
|
||||||
) {
|
|
||||||
setSelectedConnectors(chatData.initial_connectors);
|
setSelectedConnectors(chatData.initial_connectors);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load existing messages
|
// Load existing messages
|
||||||
if (chatData.messages && Array.isArray(chatData.messages)) {
|
if (chatData.messages && Array.isArray(chatData.messages)) {
|
||||||
if (
|
if (chatData.messages.length === 1 && chatData.messages[0].role === "user") {
|
||||||
chatData.messages.length === 1 &&
|
|
||||||
chatData.messages[0].role === "user"
|
|
||||||
) {
|
|
||||||
// Single user message - append to trigger LLM response
|
// Single user message - append to trigger LLM response
|
||||||
handler.append({
|
handler.append({
|
||||||
role: "user",
|
role: "user",
|
||||||
|
|
@ -205,12 +185,7 @@ export default function ResearcherPage() {
|
||||||
handler.messages.length > 0 &&
|
handler.messages.length > 0 &&
|
||||||
handler.messages[handler.messages.length - 1]?.role === "assistant"
|
handler.messages[handler.messages.length - 1]?.role === "assistant"
|
||||||
) {
|
) {
|
||||||
updateChat(
|
updateChat(chatIdParam, handler.messages, researchMode, selectedConnectors);
|
||||||
chatIdParam,
|
|
||||||
handler.messages,
|
|
||||||
researchMode,
|
|
||||||
selectedConnectors,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}, [handler.messages, handler.status, chatIdParam, isNewChat]);
|
}, [handler.messages, handler.status, chatIdParam, isNewChat]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,204 +1,185 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react";
|
import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react";
|
||||||
import {
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
Tooltip,
|
import { ArrowLeft } from "lucide-react";
|
||||||
TooltipContent,
|
import { useRouter } from "next/navigation";
|
||||||
TooltipProvider,
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
TooltipTrigger,
|
import { Button } from "@/components/ui/button";
|
||||||
} from "@/components/ui/tooltip";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useApiKey } from "@/hooks/use-api-key";
|
import { useApiKey } from "@/hooks/use-api-key";
|
||||||
|
|
||||||
const fadeIn = {
|
const fadeIn = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
visible: { opacity: 1, transition: { duration: 0.4 } },
|
visible: { opacity: 1, transition: { duration: 0.4 } },
|
||||||
};
|
};
|
||||||
|
|
||||||
const staggerContainer = {
|
const staggerContainer = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: {
|
||||||
staggerChildren: 0.1,
|
staggerChildren: 0.1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ApiKeyClient = () => {
|
const ApiKeyClient = () => {
|
||||||
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
|
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center w-full min-h-screen py-10 px-4">
|
<div className="flex justify-center w-full min-h-screen py-10 px-4">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="w-full max-w-3xl"
|
className="w-full max-w-3xl"
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
variants={staggerContainer}
|
variants={staggerContainer}
|
||||||
>
|
>
|
||||||
<motion.div className="mb-8 text-center" variants={fadeIn}>
|
<motion.div className="mb-8 text-center" variants={fadeIn}>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">API Key</h1>
|
<h1 className="text-3xl font-bold tracking-tight">API Key</h1>
|
||||||
<p className="text-muted-foreground mt-2">
|
<p className="text-muted-foreground mt-2">
|
||||||
Your API key for authenticating with the SurfSense API.
|
Your API key for authenticating with the SurfSense API.
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div variants={fadeIn}>
|
<motion.div variants={fadeIn}>
|
||||||
<Alert className="mb-8">
|
<Alert className="mb-8">
|
||||||
<IconKey className="h-4 w-4" />
|
<IconKey className="h-4 w-4" />
|
||||||
<AlertTitle>Important</AlertTitle>
|
<AlertTitle>Important</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Your API key grants full access to your account. Never share it
|
Your API key grants full access to your account. Never share it publicly or with
|
||||||
publicly or with unauthorized users.
|
unauthorized users.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div variants={fadeIn}>
|
<motion.div variants={fadeIn}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<CardTitle>Your API Key</CardTitle>
|
<CardTitle>Your API Key</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Use this key to authenticate your API requests.</CardDescription>
|
||||||
Use this key to authenticate your API requests.
|
</CardHeader>
|
||||||
</CardDescription>
|
<CardContent>
|
||||||
</CardHeader>
|
<AnimatePresence mode="wait">
|
||||||
<CardContent>
|
{isLoading ? (
|
||||||
<AnimatePresence mode="wait">
|
<motion.div
|
||||||
{isLoading ? (
|
key="loading"
|
||||||
<motion.div
|
initial={{ opacity: 0 }}
|
||||||
key="loading"
|
animate={{ opacity: 1 }}
|
||||||
initial={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
className="h-10 w-full bg-muted animate-pulse rounded-md"
|
||||||
exit={{ opacity: 0 }}
|
/>
|
||||||
className="h-10 w-full bg-muted animate-pulse rounded-md"
|
) : apiKey ? (
|
||||||
/>
|
<motion.div
|
||||||
) : apiKey ? (
|
key="api-key"
|
||||||
<motion.div
|
initial={{ opacity: 0, y: 10 }}
|
||||||
key="api-key"
|
animate={{ opacity: 1, y: 0 }}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
exit={{ opacity: 0, y: -10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||||
exit={{ opacity: 0, y: -10 }}
|
className="flex items-center space-x-2"
|
||||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
>
|
||||||
className="flex items-center space-x-2"
|
<div className="bg-muted p-3 rounded-md flex-1 font-mono text-sm overflow-x-auto whitespace-nowrap">
|
||||||
>
|
<motion.div
|
||||||
<div className="bg-muted p-3 rounded-md flex-1 font-mono text-sm overflow-x-auto whitespace-nowrap">
|
initial={{ opacity: 0 }}
|
||||||
<motion.div
|
animate={{ opacity: 1 }}
|
||||||
initial={{ opacity: 0 }}
|
transition={{ duration: 0.5 }}
|
||||||
animate={{ opacity: 1 }}
|
>
|
||||||
transition={{ duration: 0.5 }}
|
{apiKey}
|
||||||
>
|
</motion.div>
|
||||||
{apiKey}
|
</div>
|
||||||
</motion.div>
|
<TooltipProvider>
|
||||||
</div>
|
<Tooltip>
|
||||||
<TooltipProvider>
|
<TooltipTrigger asChild>
|
||||||
<Tooltip>
|
<Button
|
||||||
<TooltipTrigger asChild>
|
variant="outline"
|
||||||
<Button
|
size="icon"
|
||||||
variant="outline"
|
onClick={copyToClipboard}
|
||||||
size="icon"
|
className="flex-shrink-0"
|
||||||
onClick={copyToClipboard}
|
>
|
||||||
className="flex-shrink-0"
|
<motion.div
|
||||||
>
|
whileTap={{ scale: 0.9 }}
|
||||||
<motion.div
|
animate={copied ? { scale: [1, 1.2, 1] } : {}}
|
||||||
whileTap={{ scale: 0.9 }}
|
transition={{ duration: 0.2 }}
|
||||||
animate={copied ? { scale: [1, 1.2, 1] } : {}}
|
>
|
||||||
transition={{ duration: 0.2 }}
|
{copied ? (
|
||||||
>
|
<IconCheck className="h-4 w-4" />
|
||||||
{copied ? (
|
) : (
|
||||||
<IconCheck className="h-4 w-4" />
|
<IconCopy className="h-4 w-4" />
|
||||||
) : (
|
)}
|
||||||
<IconCopy className="h-4 w-4" />
|
</motion.div>
|
||||||
)}
|
</Button>
|
||||||
</motion.div>
|
</TooltipTrigger>
|
||||||
</Button>
|
<TooltipContent>
|
||||||
</TooltipTrigger>
|
<p>{copied ? "Copied!" : "Copy to clipboard"}</p>
|
||||||
<TooltipContent>
|
</TooltipContent>
|
||||||
<p>{copied ? "Copied!" : "Copy to clipboard"}</p>
|
</Tooltip>
|
||||||
</TooltipContent>
|
</TooltipProvider>
|
||||||
</Tooltip>
|
</motion.div>
|
||||||
</TooltipProvider>
|
) : (
|
||||||
</motion.div>
|
<motion.div
|
||||||
) : (
|
key="no-key"
|
||||||
<motion.div
|
initial={{ opacity: 0 }}
|
||||||
key="no-key"
|
animate={{ opacity: 1 }}
|
||||||
initial={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
className="text-muted-foreground text-center"
|
||||||
exit={{ opacity: 0 }}
|
>
|
||||||
className="text-muted-foreground text-center"
|
No API key found.
|
||||||
>
|
</motion.div>
|
||||||
No API key found.
|
)}
|
||||||
</motion.div>
|
</AnimatePresence>
|
||||||
)}
|
</CardContent>
|
||||||
</AnimatePresence>
|
</Card>
|
||||||
</CardContent>
|
</motion.div>
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="mt-8"
|
className="mt-8"
|
||||||
variants={fadeIn}
|
variants={fadeIn}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.3 }}
|
transition={{ delay: 0.3 }}
|
||||||
>
|
>
|
||||||
<h2 className="text-xl font-semibold mb-4 text-center">
|
<h2 className="text-xl font-semibold mb-4 text-center">How to use your API key</h2>
|
||||||
How to use your API key
|
<Card>
|
||||||
</h2>
|
<CardContent className="pt-6">
|
||||||
<Card>
|
<motion.div
|
||||||
<CardContent className="pt-6">
|
className="space-y-4"
|
||||||
<motion.div
|
initial="hidden"
|
||||||
className="space-y-4"
|
animate="visible"
|
||||||
initial="hidden"
|
variants={staggerContainer}
|
||||||
animate="visible"
|
>
|
||||||
variants={staggerContainer}
|
<motion.div variants={fadeIn}>
|
||||||
>
|
<h3 className="font-medium mb-2 text-center">Authentication</h3>
|
||||||
<motion.div variants={fadeIn}>
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
<h3 className="font-medium mb-2 text-center">
|
Include your API key in the Authorization header of your requests:
|
||||||
Authentication
|
</p>
|
||||||
</h3>
|
<motion.pre
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
className="bg-muted p-3 rounded-md mt-2 overflow-x-auto"
|
||||||
Include your API key in the Authorization header of your
|
whileHover={{ scale: 1.01 }}
|
||||||
requests:
|
transition={{ type: "spring", stiffness: 400, damping: 10 }}
|
||||||
</p>
|
>
|
||||||
<motion.pre
|
<code className="text-xs">
|
||||||
className="bg-muted p-3 rounded-md mt-2 overflow-x-auto"
|
Authorization: Bearer {apiKey || "YOUR_API_KEY"}
|
||||||
whileHover={{ scale: 1.01 }}
|
</code>
|
||||||
transition={{ type: "spring", stiffness: 400, damping: 10 }}
|
</motion.pre>
|
||||||
>
|
</motion.div>
|
||||||
<code className="text-xs">
|
</motion.div>
|
||||||
Authorization: Bearer {apiKey || "YOUR_API_KEY"}
|
</CardContent>
|
||||||
</code>
|
</Card>
|
||||||
</motion.pre>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
<div>
|
||||||
</CardContent>
|
<button
|
||||||
</Card>
|
onClick={() => router.push("/dashboard")}
|
||||||
</motion.div>
|
className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 hover:bg-primary/30 transition-colors"
|
||||||
</motion.div>
|
aria-label="Back to Dashboard"
|
||||||
<div>
|
type="button"
|
||||||
<button
|
>
|
||||||
onClick={() => router.push("/dashboard")}
|
<ArrowLeft className="h-5 w-5 text-primary" />
|
||||||
className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 hover:bg-primary/30 transition-colors"
|
</button>
|
||||||
aria-label="Back to Dashboard"
|
</div>
|
||||||
type="button"
|
</div>
|
||||||
>
|
);
|
||||||
<ArrowLeft className="h-5 w-5 text-primary" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ApiKeyClient;
|
export default ApiKeyClient;
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,32 @@
|
||||||
'use client'
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react'
|
import dynamic from "next/dynamic";
|
||||||
import dynamic from 'next/dynamic'
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
// Loading component with animation
|
// Loading component with animation
|
||||||
const LoadingComponent = () => (
|
const LoadingComponent = () => (
|
||||||
<div className="flex flex-col justify-center items-center min-h-screen">
|
<div className="flex flex-col justify-center items-center min-h-screen">
|
||||||
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mb-4"></div>
|
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mb-4"></div>
|
||||||
<p className="text-muted-foreground">Loading API Key Management...</p>
|
<p className="text-muted-foreground">Loading API Key Management...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
|
|
||||||
// Dynamically import the ApiKeyClient component
|
// Dynamically import the ApiKeyClient component
|
||||||
const ApiKeyClient = dynamic(() => import('./api-key-client'), {
|
const ApiKeyClient = dynamic(() => import("./api-key-client"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => <LoadingComponent />
|
loading: () => <LoadingComponent />,
|
||||||
})
|
});
|
||||||
|
|
||||||
export default function ClientWrapper() {
|
export default function ClientWrapper() {
|
||||||
const [isMounted, setIsMounted] = useState(false)
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsMounted(true)
|
setIsMounted(true);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
return <LoadingComponent />
|
return <LoadingComponent />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ApiKeyClient />
|
return <ApiKeyClient />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react'
|
"use client";
|
||||||
import ClientWrapper from './client-wrapper'
|
|
||||||
|
import ClientWrapper from "./client-wrapper";
|
||||||
|
|
||||||
export default function ApiKeyPage() {
|
export default function ApiKeyPage() {
|
||||||
return <ClientWrapper />
|
return <ClientWrapper />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,90 +1,92 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { Loader2 } from "lucide-react";
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from "next/navigation";
|
||||||
import { useLLMPreferences } from '@/hooks/use-llm-configs';
|
import { useEffect, useState } from "react";
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { loading, error, isOnboardingComplete } = useLLMPreferences();
|
const { loading, error, isOnboardingComplete } = useLLMPreferences();
|
||||||
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if user is authenticated
|
// Check if user is authenticated
|
||||||
const token = localStorage.getItem('surfsense_bearer_token');
|
const token = localStorage.getItem("surfsense_bearer_token");
|
||||||
if (!token) {
|
if (!token) {
|
||||||
router.push('/login');
|
router.push("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsCheckingAuth(false);
|
setIsCheckingAuth(false);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Wait for preferences to load, then check if onboarding is complete
|
// Wait for preferences to load, then check if onboarding is complete
|
||||||
if (!loading && !error && !isCheckingAuth) {
|
if (!loading && !error && !isCheckingAuth) {
|
||||||
if (!isOnboardingComplete()) {
|
if (!isOnboardingComplete()) {
|
||||||
router.push('/onboard');
|
router.push("/onboard");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [loading, error, isCheckingAuth, isOnboardingComplete, router]);
|
}, [loading, error, isCheckingAuth, isOnboardingComplete, router]);
|
||||||
|
|
||||||
// Show loading screen while checking authentication or loading preferences
|
// Show loading screen while checking authentication or loading preferences
|
||||||
if (isCheckingAuth || loading) {
|
if (isCheckingAuth || loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
||||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-xl font-medium">Loading Dashboard</CardTitle>
|
<CardTitle className="text-xl font-medium">Loading Dashboard</CardTitle>
|
||||||
<CardDescription>Checking your configuration...</CardDescription>
|
<CardDescription>Checking your configuration...</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex justify-center py-6">
|
<CardContent className="flex justify-center py-6">
|
||||||
<Loader2 className="h-12 w-12 text-primary animate-spin" />
|
<Loader2 className="h-12 w-12 text-primary animate-spin" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show error screen if there's an error loading preferences
|
// Show error screen if there's an error loading preferences
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
||||||
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
|
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-xl font-medium text-destructive">Configuration Error</CardTitle>
|
<CardTitle className="text-xl font-medium text-destructive">
|
||||||
<CardDescription>Failed to load your LLM configuration</CardDescription>
|
Configuration Error
|
||||||
</CardHeader>
|
</CardTitle>
|
||||||
<CardContent>
|
<CardDescription>Failed to load your LLM configuration</CardDescription>
|
||||||
<p className="text-sm text-muted-foreground">{error}</p>
|
</CardHeader>
|
||||||
</CardContent>
|
<CardContent>
|
||||||
</Card>
|
<p className="text-sm text-muted-foreground">{error}</p>
|
||||||
</div>
|
</CardContent>
|
||||||
);
|
</Card>
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Only render children if onboarding is complete
|
// Only render children if onboarding is complete
|
||||||
if (isOnboardingComplete()) {
|
if (isOnboardingComplete()) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This should not be reached due to redirect, but just in case
|
// This should not be reached due to redirect, but just in case
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
||||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-xl font-medium">Redirecting...</CardTitle>
|
<CardTitle className="text-xl font-medium">Redirecting...</CardTitle>
|
||||||
<CardDescription>Taking you to complete your setup</CardDescription>
|
<CardDescription>Taking you to complete your setup</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex justify-center py-6">
|
<CardContent className="flex justify-center py-6">
|
||||||
<Loader2 className="h-12 w-12 text-primary animate-spin" />
|
<Loader2 className="h-12 w-12 text-primary animate-spin" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,47 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react'
|
import { motion, type Variants } from "framer-motion";
|
||||||
import Link from 'next/link'
|
import { AlertCircle, Loader2, Plus, Search, Trash2 } from "lucide-react";
|
||||||
import { motion } from 'framer-motion'
|
import Image from "next/image";
|
||||||
import { Button } from '@/components/ui/button'
|
import Link from "next/link";
|
||||||
import { Plus, Search, Trash2, AlertCircle, Loader2 } from 'lucide-react'
|
import { useRouter } from "next/navigation";
|
||||||
import { Tilt } from '@/components/ui/tilt'
|
import { useEffect, useState } from "react";
|
||||||
import { Spotlight } from '@/components/ui/spotlight'
|
import { toast } from "sonner";
|
||||||
import { Logo } from '@/components/Logo';
|
import { Logo } from "@/components/Logo";
|
||||||
import { ThemeTogglerComponent } from '@/components/theme/theme-toggle';
|
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
|
||||||
import { UserDropdown } from '@/components/UserDropdown';
|
import { UserDropdown } from "@/components/UserDropdown";
|
||||||
import { toast } from 'sonner';
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Card,
|
||||||
AlertDescription,
|
CardContent,
|
||||||
AlertTitle,
|
CardDescription,
|
||||||
} from "@/components/ui/alert";
|
CardFooter,
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
CardHeader,
|
||||||
import { useSearchSpaces } from '@/hooks/use-search-spaces';
|
CardTitle,
|
||||||
import { apiClient } from '@/lib/api';
|
} from "@/components/ui/card";
|
||||||
import { useRouter } from 'next/navigation';
|
import { Spotlight } from "@/components/ui/spotlight";
|
||||||
|
import { Tilt } from "@/components/ui/tilt";
|
||||||
|
import { useSearchSpaces } from "@/hooks/use-search-spaces";
|
||||||
|
import { apiClient } from "@/lib/api";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
is_superuser: boolean;
|
is_superuser: boolean;
|
||||||
is_verified: boolean;
|
is_verified: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -46,356 +50,354 @@ interface User {
|
||||||
* @returns Formatted date string (e.g., "Jan 1, 2023")
|
* @returns Formatted date string (e.g., "Jan 1, 2023")
|
||||||
*/
|
*/
|
||||||
const formatDate = (dateString: string): string => {
|
const formatDate = (dateString: string): string => {
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
return new Date(dateString).toLocaleDateString("en-US", {
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
month: 'short',
|
month: "short",
|
||||||
day: 'numeric'
|
day: "numeric",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loading screen component with animation
|
* Loading screen component with animation
|
||||||
*/
|
*/
|
||||||
const LoadingScreen = () => {
|
const LoadingScreen = () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
|
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
>
|
>
|
||||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-xl font-medium">Loading</CardTitle>
|
<CardTitle className="text-xl font-medium">Loading</CardTitle>
|
||||||
<CardDescription>Fetching your search spaces...</CardDescription>
|
<CardDescription>Fetching your search spaces...</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex justify-center py-6">
|
<CardContent className="flex justify-center py-6">
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ rotate: 360 }}
|
animate={{ rotate: 360 }}
|
||||||
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
|
||||||
>
|
>
|
||||||
<Loader2 className="h-12 w-12 text-primary" />
|
<Loader2 className="h-12 w-12 text-primary" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="border-t pt-4 text-sm text-muted-foreground">
|
<CardFooter className="border-t pt-4 text-sm text-muted-foreground">
|
||||||
This may take a moment
|
This may take a moment
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error screen component with animation
|
* Error screen component with animation
|
||||||
*/
|
*/
|
||||||
const ErrorScreen = ({ message }: { message: string }) => {
|
const ErrorScreen = ({ message }: { message: string }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
|
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
>
|
>
|
||||||
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
|
<Card className="w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||||
<CardTitle className="text-xl font-medium">Error</CardTitle>
|
<CardTitle className="text-xl font-medium">Error</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>Something went wrong</CardDescription>
|
<CardDescription>Something went wrong</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Alert variant="destructive" className="bg-destructive/10 border-destructive/30">
|
<Alert variant="destructive" className="bg-destructive/10 border-destructive/30">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Error Details</AlertTitle>
|
<AlertTitle>Error Details</AlertTitle>
|
||||||
<AlertDescription className="mt-2">
|
<AlertDescription className="mt-2">{message}</AlertDescription>
|
||||||
{message}
|
</Alert>
|
||||||
</AlertDescription>
|
</CardContent>
|
||||||
</Alert>
|
<CardFooter className="flex justify-end gap-2 border-t pt-4">
|
||||||
</CardContent>
|
<Button variant="outline" onClick={() => router.refresh()}>
|
||||||
<CardFooter className="flex justify-end gap-2 border-t pt-4">
|
Try Again
|
||||||
<Button variant="outline" onClick={() => router.refresh()}>
|
</Button>
|
||||||
Try Again
|
<Button onClick={() => router.push("/")}>Go Home</Button>
|
||||||
</Button>
|
</CardFooter>
|
||||||
<Button onClick={() => router.push('/')}>
|
</Card>
|
||||||
Go Home
|
</motion.div>
|
||||||
</Button>
|
</div>
|
||||||
</CardFooter>
|
);
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DashboardPage = () => {
|
const DashboardPage = () => {
|
||||||
// Animation variants
|
// Animation variants
|
||||||
const containerVariants = {
|
const containerVariants: Variants = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: {
|
||||||
staggerChildren: 0.1,
|
staggerChildren: 0.1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const itemVariants = {
|
const itemVariants: Variants = {
|
||||||
hidden: { y: 20, opacity: 0 },
|
hidden: { y: 20, opacity: 0 },
|
||||||
visible: {
|
visible: {
|
||||||
y: 0,
|
y: 0,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: {
|
||||||
type: "spring",
|
type: "spring",
|
||||||
stiffness: 300,
|
stiffness: 300,
|
||||||
damping: 24,
|
damping: 24,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const router = useRouter();
|
const { searchSpaces, loading, error, refreshSearchSpaces } = useSearchSpaces();
|
||||||
const { searchSpaces, loading, error, refreshSearchSpaces } = useSearchSpaces();
|
|
||||||
|
|
||||||
// User state management
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
const [isLoadingUser, setIsLoadingUser] = useState(true);
|
|
||||||
const [userError, setUserError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Fetch user details
|
// User state management
|
||||||
useEffect(() => {
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const fetchUser = async () => {
|
const [isLoadingUser, setIsLoadingUser] = useState(true);
|
||||||
try {
|
const [userError, setUserError] = useState<string | null>(null);
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
try {
|
// Fetch user details
|
||||||
const userData = await apiClient.get<User>('users/me');
|
useEffect(() => {
|
||||||
setUser(userData);
|
const fetchUser = async () => {
|
||||||
setUserError(null);
|
try {
|
||||||
} catch (error) {
|
if (typeof window === "undefined") return;
|
||||||
console.error('Error fetching user:', error);
|
|
||||||
setUserError(error instanceof Error ? error.message : 'Unknown error occurred');
|
|
||||||
} finally {
|
|
||||||
setIsLoadingUser(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in fetchUser:', error);
|
|
||||||
setIsLoadingUser(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchUser();
|
try {
|
||||||
}, []);
|
const userData = await apiClient.get<User>("users/me");
|
||||||
|
setUser(userData);
|
||||||
|
setUserError(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching user:", error);
|
||||||
|
setUserError(error instanceof Error ? error.message : "Unknown error occurred");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingUser(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in fetchUser:", error);
|
||||||
|
setIsLoadingUser(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Create user object for UserDropdown
|
fetchUser();
|
||||||
const customUser = {
|
}, []);
|
||||||
name: user?.email ? user.email.split('@')[0] : 'User',
|
|
||||||
email: user?.email || (isLoadingUser ? 'Loading...' : userError ? 'Error loading user' : 'Unknown User'),
|
|
||||||
avatar: '/icon-128.png', // Default avatar
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <LoadingScreen />;
|
// Create user object for UserDropdown
|
||||||
if (error) return <ErrorScreen message={error} />;
|
const customUser = {
|
||||||
|
name: user?.email ? user.email.split("@")[0] : "User",
|
||||||
|
email:
|
||||||
|
user?.email ||
|
||||||
|
(isLoadingUser ? "Loading..." : userError ? "Error loading user" : "Unknown User"),
|
||||||
|
avatar: "/icon-128.png", // Default avatar
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteSearchSpace = async (id: number) => {
|
if (loading) return <LoadingScreen />;
|
||||||
// Send DELETE request to the API
|
if (error) return <ErrorScreen message={error} />;
|
||||||
try {
|
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${localStorage.getItem('surfsense_bearer_token')}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
toast.error("Failed to delete search space");
|
|
||||||
throw new Error("Failed to delete search space");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the search spaces list after successful deletion
|
|
||||||
refreshSearchSpaces();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting search space:', error);
|
|
||||||
toast.error("An error occurred while deleting the search space");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.success("Search space deleted successfully");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
const handleDeleteSearchSpace = async (id: number) => {
|
||||||
<motion.div
|
// Send DELETE request to the API
|
||||||
className="container mx-auto py-10"
|
try {
|
||||||
initial="hidden"
|
const response = await fetch(
|
||||||
animate="visible"
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${id}`,
|
||||||
variants={containerVariants}
|
{
|
||||||
>
|
method: "DELETE",
|
||||||
<motion.div className="flex flex-col space-y-6" variants={itemVariants}>
|
headers: {
|
||||||
<div className="flex flex-row space-x-4 justify-between">
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
<div className="flex flex-row space-x-4">
|
},
|
||||||
<Logo className="w-10 h-10 rounded-md" />
|
}
|
||||||
<div className="flex flex-col space-y-2">
|
);
|
||||||
<h1 className="text-4xl font-bold">SurfSense Dashboard</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Welcome to your SurfSense dashboard.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<UserDropdown user={customUser} />
|
|
||||||
<ThemeTogglerComponent />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col space-y-6 mt-6">
|
if (!response.ok) {
|
||||||
<div className="flex justify-between items-center">
|
toast.error("Failed to delete search space");
|
||||||
<h2 className="text-2xl font-semibold">Your Search Spaces</h2>
|
throw new Error("Failed to delete search space");
|
||||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
}
|
||||||
<Link href="/dashboard/searchspaces">
|
|
||||||
<Button className="h-10">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
Create Search Space
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
// Refresh the search spaces list after successful deletion
|
||||||
{searchSpaces && searchSpaces.map((space) => (
|
refreshSearchSpaces();
|
||||||
<Link href={`/dashboard/${space.id}/documents`} key={space.id}>
|
} catch (error) {
|
||||||
<motion.div
|
console.error("Error deleting search space:", error);
|
||||||
key={space.id}
|
toast.error("An error occurred while deleting the search space");
|
||||||
variants={itemVariants}
|
return;
|
||||||
className="aspect-[4/3]"
|
}
|
||||||
>
|
toast.success("Search space deleted successfully");
|
||||||
|
};
|
||||||
|
|
||||||
<Tilt
|
return (
|
||||||
rotationFactor={6}
|
<motion.div
|
||||||
isRevese
|
className="container mx-auto py-10"
|
||||||
springOptions={{
|
initial="hidden"
|
||||||
stiffness: 26.7,
|
animate="visible"
|
||||||
damping: 4.1,
|
variants={containerVariants}
|
||||||
mass: 0.2,
|
>
|
||||||
}}
|
<motion.div className="flex flex-col space-y-6" variants={itemVariants}>
|
||||||
className="group relative rounded-lg h-full"
|
<div className="flex flex-row space-x-4 justify-between">
|
||||||
>
|
<div className="flex flex-row space-x-4">
|
||||||
<Spotlight
|
<Logo className="w-10 h-10 rounded-md" />
|
||||||
className="z-10 from-blue-500/20 via-blue-300/10 to-blue-200/5 blur-2xl"
|
<div className="flex flex-col space-y-2">
|
||||||
size={248}
|
<h1 className="text-4xl font-bold">SurfSense Dashboard</h1>
|
||||||
springOptions={{
|
<p className="text-muted-foreground">Welcome to your SurfSense dashboard.</p>
|
||||||
stiffness: 26.7,
|
</div>
|
||||||
damping: 4.1,
|
</div>
|
||||||
mass: 0.2,
|
<div className="flex items-center space-x-3">
|
||||||
}}
|
<UserDropdown user={customUser} />
|
||||||
/>
|
<ThemeTogglerComponent />
|
||||||
<div className="flex flex-col h-full overflow-hidden rounded-xl border bg-muted/30 backdrop-blur-sm transition-all hover:border-primary/50">
|
</div>
|
||||||
<div className="relative h-32 w-full overflow-hidden">
|
</div>
|
||||||
<img
|
|
||||||
src="https://images.unsplash.com/photo-1519389950473-47ba0277781c?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1740&q=80"
|
|
||||||
alt={space.name}
|
|
||||||
className="h-full w-full object-cover grayscale duration-700 group-hover:grayscale-0"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-background/80 to-transparent" />
|
|
||||||
<div className="absolute top-2 right-2">
|
|
||||||
<div onClick={(e) => e.preventDefault()}>
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-full bg-background/50 backdrop-blur-sm hover:bg-destructive/90 cursor-pointer"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete Search Space</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Are you sure you want to delete "{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>
|
|
||||||
|
|
||||||
</motion.div>
|
<div className="flex flex-col space-y-6 mt-6">
|
||||||
</Link>
|
<div className="flex justify-between items-center">
|
||||||
))}
|
<h2 className="text-2xl font-semibold">Your Search Spaces</h2>
|
||||||
|
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||||
|
<Link href="/dashboard/searchspaces">
|
||||||
|
<Button className="h-10">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Search Space
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{searchSpaces.length === 0 && (
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<motion.div
|
{searchSpaces &&
|
||||||
variants={itemVariants}
|
searchSpaces.length > 0 &&
|
||||||
className="col-span-full flex flex-col items-center justify-center p-12 text-center"
|
searchSpaces.map((space) => (
|
||||||
>
|
<Link href={`/dashboard/${space.id}/documents`} key={space.id}>
|
||||||
<div className="rounded-full bg-muted/50 p-4 mb-4">
|
<motion.div key={space.id} variants={itemVariants} className="aspect-[4/3]">
|
||||||
<Search className="h-8 w-8 text-muted-foreground" />
|
<Tilt
|
||||||
</div>
|
rotationFactor={6}
|
||||||
<h3 className="text-lg font-medium mb-2">No search spaces found</h3>
|
isRevese
|
||||||
<p className="text-muted-foreground mb-6">Create your first search space to get started</p>
|
springOptions={{
|
||||||
<Link href="/dashboard/searchspaces">
|
stiffness: 26.7,
|
||||||
<Button>
|
damping: 4.1,
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
mass: 0.2,
|
||||||
Create Search Space
|
}}
|
||||||
</Button>
|
className="group relative rounded-lg h-full"
|
||||||
</Link>
|
>
|
||||||
</motion.div>
|
<Spotlight
|
||||||
)}
|
className="z-10 from-blue-500/20 via-blue-300/10 to-blue-200/5 blur-2xl"
|
||||||
|
size={248}
|
||||||
|
springOptions={{
|
||||||
|
stiffness: 26.7,
|
||||||
|
damping: 4.1,
|
||||||
|
mass: 0.2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col h-full overflow-hidden rounded-xl border bg-muted/30 backdrop-blur-sm transition-all hover:border-primary/50">
|
||||||
|
<div className="relative h-32 w-full overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src="https://images.unsplash.com/photo-1519389950473-47ba0277781c?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1740&q=80"
|
||||||
|
alt={space.name}
|
||||||
|
className="h-full w-full object-cover grayscale duration-700 group-hover:grayscale-0"
|
||||||
|
width={248}
|
||||||
|
height={248}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-background/80 to-transparent" />
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<div>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full bg-background/50 backdrop-blur-sm hover:bg-destructive/90 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Search Space</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete "{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 && (
|
<div className="flex flex-1 flex-col justify-between p-4">
|
||||||
<motion.div
|
<div>
|
||||||
variants={itemVariants}
|
<h3 className="font-medium text-lg">{space.name}</h3>
|
||||||
className="aspect-[4/3]"
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
>
|
{space.description}
|
||||||
<Tilt
|
</p>
|
||||||
rotationFactor={6}
|
</div>
|
||||||
isRevese
|
<div className="mt-4 flex justify-between text-xs text-muted-foreground">
|
||||||
springOptions={{
|
{/* <span>{space.title}</span> */}
|
||||||
stiffness: 26.7,
|
<span>Created {formatDate(space.created_at)}</span>
|
||||||
damping: 4.1,
|
</div>
|
||||||
mass: 0.2,
|
</div>
|
||||||
}}
|
</div>
|
||||||
className="group relative rounded-lg h-full"
|
</Tilt>
|
||||||
>
|
</motion.div>
|
||||||
<Link href="/dashboard/searchspaces" className="flex h-full">
|
</Link>
|
||||||
<div className="flex flex-col items-center justify-center h-full w-full rounded-xl border border-dashed bg-muted/10 hover:border-primary/50 transition-colors">
|
))}
|
||||||
<Plus className="h-10 w-10 mb-3 text-muted-foreground" />
|
|
||||||
<span className="text-sm font-medium">Add New Search Space</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</Tilt>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DashboardPage
|
{searchSpaces.length === 0 && (
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
className="col-span-full flex flex-col items-center justify-center p-12 text-center"
|
||||||
|
>
|
||||||
|
<div className="rounded-full bg-muted/50 p-4 mb-4">
|
||||||
|
<Search className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium mb-2">No search spaces found</h3>
|
||||||
|
<p className="text-muted-foreground mb-6">
|
||||||
|
Create your first search space to get started
|
||||||
|
</p>
|
||||||
|
<Link href="/dashboard/searchspaces">
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Search Space
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchSpaces.length > 0 && (
|
||||||
|
<motion.div variants={itemVariants} className="aspect-[4/3]">
|
||||||
|
<Tilt
|
||||||
|
rotationFactor={6}
|
||||||
|
isRevese
|
||||||
|
springOptions={{
|
||||||
|
stiffness: 26.7,
|
||||||
|
damping: 4.1,
|
||||||
|
mass: 0.2,
|
||||||
|
}}
|
||||||
|
className="group relative rounded-lg h-full"
|
||||||
|
>
|
||||||
|
<Link href="/dashboard/searchspaces" className="flex h-full">
|
||||||
|
<div className="flex flex-col items-center justify-center h-full w-full rounded-xl border border-dashed bg-muted/10 hover:border-primary/50 transition-colors">
|
||||||
|
<Plus className="h-10 w-10 mb-3 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">Add New Search Space</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</Tilt>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,55 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { SearchSpaceForm } from "@/components/search-space-form";
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { SearchSpaceForm } from "@/components/search-space-form";
|
||||||
export default function SearchSpacesPage() {
|
export default function SearchSpacesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const handleCreateSearchSpace = async (data: { name: string; description: string }) => {
|
const handleCreateSearchSpace = async (data: { name: string; description: string }) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`, {
|
const response = await fetch(
|
||||||
method: 'POST',
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`,
|
||||||
headers: {
|
{
|
||||||
'Content-Type': 'application/json',
|
method: "POST",
|
||||||
Authorization: `Bearer ${localStorage.getItem('surfsense_bearer_token')}`,
|
headers: {
|
||||||
},
|
"Content-Type": "application/json",
|
||||||
body: JSON.stringify(data),
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
});
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
if (!response.ok) {
|
}
|
||||||
toast.error("Failed to create search space");
|
);
|
||||||
throw new Error("Failed to create search space");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
toast.success("Search space created successfully", {
|
|
||||||
description: `"${data.name}" has been created.`,
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push(`/dashboard`);
|
if (!response.ok) {
|
||||||
|
toast.error("Failed to create search space");
|
||||||
return result;
|
throw new Error("Failed to create search space");
|
||||||
} catch (error: any) {
|
}
|
||||||
console.error('Error creating search space:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
const result = await response.json();
|
||||||
<motion.div
|
|
||||||
className="container mx-auto py-10"
|
toast.success("Search space created successfully", {
|
||||||
initial={{ opacity: 0 }}
|
description: `"${data.name}" has been created.`,
|
||||||
animate={{ opacity: 1 }}
|
});
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
>
|
router.push(`/dashboard`);
|
||||||
<div className="mx-auto max-w-5xl">
|
|
||||||
<SearchSpaceForm onSubmit={handleCreateSearchSpace} />
|
return result;
|
||||||
</div>
|
} catch (error) {
|
||||||
</motion.div>
|
console.error("Error creating search space:", error);
|
||||||
);
|
throw error;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="container mx-auto py-10"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<div className="mx-auto max-w-5xl">
|
||||||
|
<SearchSpaceForm onSubmit={handleCreateSearchSpace} />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,37 @@
|
||||||
import { source } from '@/lib/source';
|
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/page";
|
||||||
import {
|
import { notFound } from "next/navigation";
|
||||||
DocsBody,
|
import { source } from "@/lib/source";
|
||||||
DocsDescription,
|
import { getMDXComponents } from "@/mdx-components";
|
||||||
DocsPage,
|
|
||||||
DocsTitle,
|
export default async function Page(props: { params: Promise<{ slug?: string[] }> }) {
|
||||||
} from 'fumadocs-ui/page';
|
const params = await props.params;
|
||||||
import { notFound } from 'next/navigation';
|
const page = source.getPage(params.slug);
|
||||||
import { getMDXComponents } from '@/mdx-components';
|
if (!page) notFound();
|
||||||
|
|
||||||
export default async function Page(props: {
|
const MDX = page.data.body;
|
||||||
params: Promise<{ slug?: string[] }>;
|
|
||||||
}) {
|
return (
|
||||||
const params = await props.params;
|
<DocsPage toc={page.data.toc} full={page.data.full}>
|
||||||
const page = source.getPage(params.slug);
|
<DocsTitle>{page.data.title}</DocsTitle>
|
||||||
if (!page) notFound();
|
<DocsDescription>{page.data.description}</DocsDescription>
|
||||||
|
<DocsBody>
|
||||||
const MDX = page.data.body;
|
<MDX components={getMDXComponents()} />
|
||||||
|
</DocsBody>
|
||||||
return (
|
</DocsPage>
|
||||||
<DocsPage toc={page.data.toc} full={page.data.full}>
|
);
|
||||||
<DocsTitle>{page.data.title}</DocsTitle>
|
|
||||||
<DocsDescription>{page.data.description}</DocsDescription>
|
|
||||||
<DocsBody>
|
|
||||||
<MDX components={getMDXComponents()} />
|
|
||||||
</DocsBody>
|
|
||||||
</DocsPage>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
return source.generateParams();
|
return source.generateParams();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(props: { params: Promise<{ slug?: string[] }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
const page = source.getPage(params.slug);
|
||||||
|
if (!page) notFound();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: page.data.title,
|
||||||
|
description: page.data.description,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata(props: {
|
|
||||||
params: Promise<{ slug?: string[] }>;
|
|
||||||
}) {
|
|
||||||
const params = await props.params;
|
|
||||||
const page = source.getPage(params.slug);
|
|
||||||
if (!page) notFound();
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: page.data.title,
|
|
||||||
description: page.data.description,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { source } from '@/lib/source';
|
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||||
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
import type { ReactNode } from "react";
|
||||||
import type { ReactNode } from 'react';
|
import { baseOptions } from "@/app/layout.config";
|
||||||
import { baseOptions } from '@/app/layout.config';
|
import { source } from "@/lib/source";
|
||||||
|
|
||||||
export default function Layout({ children }: { children: ReactNode }) {
|
export default function Layout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<DocsLayout tree={source.pageTree} {...baseOptions}>
|
<DocsLayout tree={source.pageTree} {...baseOptions}>
|
||||||
{children}
|
{children}
|
||||||
</DocsLayout>
|
</DocsLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,160 +1,160 @@
|
||||||
@import 'tailwindcss';
|
@import "tailwindcss";
|
||||||
@import 'fumadocs-ui/css/neutral.css';
|
@import "fumadocs-ui/css/neutral.css";
|
||||||
@import 'fumadocs-ui/css/preset.css';
|
@import "fumadocs-ui/css/preset.css";
|
||||||
|
|
||||||
@plugin "tailwindcss-animate";
|
@plugin "tailwindcss-animate";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.205 0 0);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.97 0 0);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.97 0 0);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.97 0 0);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.922 0 0);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.922 0 0);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.708 0 0);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
--syntax-bg: #f5f5f5;
|
--syntax-bg: #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.145 0 0);
|
--card: oklch(0.145 0 0);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.145 0 0);
|
--popover: oklch(0.145 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.985 0 0);
|
--primary: oklch(0.985 0 0);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.269 0 0);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.269 0 0);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.269 0 0);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.396 0.141 25.723);
|
--destructive: oklch(0.396 0.141 25.723);
|
||||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||||
--border: oklch(0.269 0 0);
|
--border: oklch(0.269 0 0);
|
||||||
--input: oklch(0.269 0 0);
|
--input: oklch(0.269 0 0);
|
||||||
--ring: oklch(0.439 0 0);
|
--ring: oklch(0.439 0 0);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.205 0 0);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(0.269 0 0);
|
--sidebar-border: oklch(0.269 0 0);
|
||||||
--sidebar-ring: oklch(0.439 0 0);
|
--sidebar-ring: oklch(0.439 0 0);
|
||||||
--syntax-bg: #1e1e1e;
|
--syntax-bg: #1e1e1e;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary: var(--secondary);
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
:root {
|
:root {
|
||||||
--sidebar-background: 0 0% 98%;
|
--sidebar-background: 0 0% 98%;
|
||||||
--sidebar-foreground: 240 5.3% 26.1%;
|
--sidebar-foreground: 240 5.3% 26.1%;
|
||||||
--sidebar-primary: 240 5.9% 10%;
|
--sidebar-primary: 240 5.9% 10%;
|
||||||
--sidebar-primary-foreground: 0 0% 98%;
|
--sidebar-primary-foreground: 0 0% 98%;
|
||||||
--sidebar-accent: 240 4.8% 95.9%;
|
--sidebar-accent: 240 4.8% 95.9%;
|
||||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||||
--sidebar-border: 220 13% 91%;
|
--sidebar-border: 220 13% 91%;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--sidebar-background: 240 5.9% 10%;
|
--sidebar-background: 240 5.9% 10%;
|
||||||
--sidebar-foreground: 240 4.8% 95.9%;
|
--sidebar-foreground: 240 4.8% 95.9%;
|
||||||
--sidebar-primary: 224.3 76.3% 48%;
|
--sidebar-primary: 224.3 76.3% 48%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 240 3.7% 15.9%;
|
--sidebar-accent: 240 3.7% 15.9%;
|
||||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||||
--sidebar-border: 240 3.7% 15.9%;
|
--sidebar-border: 240 3.7% 15.9%;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}'
|
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
||||||
|
|
||||||
export const baseOptions: BaseLayoutProps = {
|
export const baseOptions: BaseLayoutProps = {
|
||||||
nav: {
|
nav: {
|
||||||
title: 'SurfSense Documentation',
|
title: "SurfSense Documentation",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,108 +1,102 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { cn } from "@/lib/utils";
|
import { RootProvider } from "fumadocs-ui/provider";
|
||||||
import { Roboto } from "next/font/google";
|
import { Roboto } from "next/font/google";
|
||||||
|
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
|
||||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||||
import { RootProvider } from 'fumadocs-ui/provider';
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const roboto = Roboto({
|
const roboto = Roboto({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
weight: ["400", "500", "700"],
|
weight: ["400", "500", "700"],
|
||||||
display: 'swap',
|
display: "swap",
|
||||||
variable: '--font-roboto',
|
variable: "--font-roboto",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "SurfSense – Customizable AI Research & Knowledge Management Assistant",
|
title: "SurfSense – Customizable AI Research & Knowledge Management Assistant",
|
||||||
description:
|
description:
|
||||||
"SurfSense is an AI-powered research assistant that integrates with tools like Notion, GitHub, Slack, and more to help you efficiently manage, search, and chat with your documents. Generate podcasts, perform hybrid search, and unlock insights from your knowledge base.",
|
"SurfSense is an AI-powered research assistant that integrates with tools like Notion, GitHub, Slack, and more to help you efficiently manage, search, and chat with your documents. Generate podcasts, perform hybrid search, and unlock insights from your knowledge base.",
|
||||||
keywords: [
|
keywords: [
|
||||||
"SurfSense",
|
"SurfSense",
|
||||||
"AI research assistant",
|
"AI research assistant",
|
||||||
"AI knowledge management",
|
"AI knowledge management",
|
||||||
"AI document assistant",
|
"AI document assistant",
|
||||||
"customizable AI assistant",
|
"customizable AI assistant",
|
||||||
"notion integration",
|
"notion integration",
|
||||||
"slack integration",
|
"slack integration",
|
||||||
"github integration",
|
"github integration",
|
||||||
"hybrid search",
|
"hybrid search",
|
||||||
"vector search",
|
"vector search",
|
||||||
"RAG",
|
"RAG",
|
||||||
"LangChain",
|
"LangChain",
|
||||||
"FastAPI",
|
"FastAPI",
|
||||||
"LLM apps",
|
"LLM apps",
|
||||||
"AI document chat",
|
"AI document chat",
|
||||||
"knowledge management AI",
|
"knowledge management AI",
|
||||||
"AI-powered document search",
|
"AI-powered document search",
|
||||||
"personal AI assistant",
|
"personal AI assistant",
|
||||||
"AI research tools",
|
"AI research tools",
|
||||||
"AI podcast generator",
|
"AI podcast generator",
|
||||||
"AI knowledge base",
|
"AI knowledge base",
|
||||||
"AI document assistant tools",
|
"AI document assistant tools",
|
||||||
"AI-powered search assistant",
|
"AI-powered search assistant",
|
||||||
],
|
],
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "SurfSense – AI Research & Knowledge Management Assistant",
|
title: "SurfSense – AI Research & Knowledge Management Assistant",
|
||||||
description:
|
description:
|
||||||
"Connect your documents and tools like Notion, Slack, GitHub, and more to your private AI assistant. SurfSense offers powerful search, document chat, podcast generation, and RAG APIs to enhance your workflow.",
|
"Connect your documents and tools like Notion, Slack, GitHub, and more to your private AI assistant. SurfSense offers powerful search, document chat, podcast generation, and RAG APIs to enhance your workflow.",
|
||||||
url: "https://surfsense.net",
|
url: "https://surfsense.net",
|
||||||
siteName: "SurfSense",
|
siteName: "SurfSense",
|
||||||
type: "website",
|
type: "website",
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "https://surfsense.net/og-image.png",
|
url: "https://surfsense.net/og-image.png",
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
alt: "SurfSense AI Research Assistant",
|
alt: "SurfSense AI Research Assistant",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
locale: "en_US",
|
locale: "en_US",
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "SurfSense – AI Assistant for Research & Knowledge Management",
|
title: "SurfSense – AI Assistant for Research & Knowledge Management",
|
||||||
description:
|
description:
|
||||||
"Have your own NotebookLM or Perplexity, but better. SurfSense connects external tools, allows chat with your documents, and generates fast, high-quality podcasts.",
|
"Have your own NotebookLM or Perplexity, but better. SurfSense connects external tools, allows chat with your documents, and generates fast, high-quality podcasts.",
|
||||||
creator: "https://surfsense.net",
|
creator: "https://surfsense.net",
|
||||||
site: "https://surfsense.net",
|
site: "https://surfsense.net",
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "https://surfsense.net/og-image-twitter.png",
|
url: "https://surfsense.net/og-image-twitter.png",
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
alt: "SurfSense AI Assistant Preview",
|
alt: "SurfSense AI Assistant Preview",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body
|
<body className={cn(roboto.className, "bg-white dark:bg-black antialiased h-full w-full")}>
|
||||||
className={cn(
|
<ThemeProvider
|
||||||
roboto.className,
|
attribute="class"
|
||||||
"bg-white dark:bg-black antialiased h-full w-full"
|
enableSystem
|
||||||
)}
|
disableTransitionOnChange
|
||||||
>
|
defaultTheme="light"
|
||||||
<ThemeProvider
|
>
|
||||||
attribute="class"
|
<RootProvider>
|
||||||
enableSystem
|
{children}
|
||||||
disableTransitionOnChange
|
<Toaster />
|
||||||
defaultTheme="light"
|
</RootProvider>
|
||||||
>
|
</ThemeProvider>
|
||||||
<RootProvider>
|
</body>
|
||||||
{children}
|
</html>
|
||||||
<Toaster />
|
);
|
||||||
</RootProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,42 @@
|
||||||
"use client";
|
"use client";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export const AmbientBackground = () => {
|
export const AmbientBackground = () => {
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute left-0 top-0 z-0 h-screen w-screen">
|
<div className="pointer-events-none absolute left-0 top-0 z-0 h-screen w-screen">
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
transform: "translateY(-350px) rotate(-45deg)",
|
transform: "translateY(-350px) rotate(-45deg)",
|
||||||
width: "560px",
|
width: "560px",
|
||||||
height: "1380px",
|
height: "1380px",
|
||||||
background:
|
background:
|
||||||
"radial-gradient(68.54% 68.72% at 55.02% 31.46%, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.02) 50%, rgba(59, 130, 246, 0) 100%)",
|
"radial-gradient(68.54% 68.72% at 55.02% 31.46%, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0.02) 50%, rgba(59, 130, 246, 0) 100%)",
|
||||||
}}
|
}}
|
||||||
className="absolute left-0 top-0"
|
className="absolute left-0 top-0"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
transform: "rotate(-45deg) translate(5%, -50%)",
|
transform: "rotate(-45deg) translate(5%, -50%)",
|
||||||
transformOrigin: "top left",
|
transformOrigin: "top left",
|
||||||
width: "240px",
|
width: "240px",
|
||||||
height: "1380px",
|
height: "1380px",
|
||||||
background:
|
background:
|
||||||
"radial-gradient(50% 50% at 50% 50%, rgba(59, 130, 246, 0.06) 0%, rgba(59, 130, 246, 0.02) 80%, transparent 100%)",
|
"radial-gradient(50% 50% at 50% 50%, rgba(59, 130, 246, 0.06) 0%, rgba(59, 130, 246, 0.02) 80%, transparent 100%)",
|
||||||
}}
|
}}
|
||||||
className="absolute left-0 top-0"
|
className="absolute left-0 top-0"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
borderRadius: "20px",
|
borderRadius: "20px",
|
||||||
transform: "rotate(-45deg) translate(-180%, -70%)",
|
transform: "rotate(-45deg) translate(-180%, -70%)",
|
||||||
transformOrigin: "top left",
|
transformOrigin: "top left",
|
||||||
width: "240px",
|
width: "240px",
|
||||||
height: "1380px",
|
height: "1380px",
|
||||||
background:
|
background:
|
||||||
"radial-gradient(50% 50% at 50% 50%, rgba(59, 130, 246, 0.04) 0%, rgba(59, 130, 246, 0.02) 80%, transparent 100%)",
|
"radial-gradient(50% 50% at 50% 50%, rgba(59, 130, 246, 0.04) 0%, rgba(59, 130, 246, 0.02) 80%, transparent 100%)",
|
||||||
}}
|
}}
|
||||||
className="absolute left-0 top-0"
|
className="absolute left-0 top-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,92 +1,99 @@
|
||||||
"use client";
|
"use client";
|
||||||
import React from "react";
|
|
||||||
import { IconBrandGoogleFilled } from "@tabler/icons-react";
|
import { IconBrandGoogleFilled } from "@tabler/icons-react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { AmbientBackground } from "./AmbientBackground";
|
import { AmbientBackground } from "./AmbientBackground";
|
||||||
|
|
||||||
export function GoogleLoginButton() {
|
export function GoogleLoginButton() {
|
||||||
const handleGoogleLogin = () => {
|
const handleGoogleLogin = () => {
|
||||||
// Redirect to Google OAuth authorization URL
|
// Redirect to Google OAuth authorization URL
|
||||||
fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize`)
|
fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/google/authorize`)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to get authorization URL');
|
throw new Error("Failed to get authorization URL");
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.authorization_url) {
|
if (data.authorization_url) {
|
||||||
window.location.href = data.authorization_url;
|
window.location.href = data.authorization_url;
|
||||||
} else {
|
} else {
|
||||||
console.error('No authorization URL received');
|
console.error("No authorization URL received");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Error during Google login:', error);
|
console.error("Error during Google login:", error);
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full overflow-hidden">
|
<div className="relative w-full overflow-hidden">
|
||||||
<AmbientBackground />
|
<AmbientBackground />
|
||||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||||
<Logo className="rounded-md" />
|
<Logo className="rounded-md" />
|
||||||
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
|
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
|
||||||
Welcome Back
|
Welcome Back
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -5 }}
|
initial={{ opacity: 0, y: -5 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
className="mb-4 w-full overflow-hidden rounded-lg border border-yellow-200 bg-yellow-50 text-yellow-900 shadow-sm dark:border-yellow-900/30 dark:bg-yellow-900/20 dark:text-yellow-200"
|
className="mb-4 w-full overflow-hidden rounded-lg border border-yellow-200 bg-yellow-50 text-yellow-900 shadow-sm dark:border-yellow-900/30 dark:bg-yellow-900/20 dark:text-yellow-200"
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="flex items-center gap-2 p-4"
|
className="flex items-center gap-2 p-4"
|
||||||
initial={{ x: -5 }}
|
initial={{ x: -5 }}
|
||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
transition={{ delay: 0.1, duration: 0.2 }}
|
transition={{ delay: 0.1, duration: 0.2 }}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="16"
|
width="16"
|
||||||
height="16"
|
height="16"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
>
|
>
|
||||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
<title>Google Logo</title>
|
||||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
</svg>
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
<div className="ml-1">
|
</svg>
|
||||||
<p className="text-sm font-medium">
|
<div className="ml-1">
|
||||||
SurfSense Cloud is currently in development. Check <a href="/docs" className="text-blue-600 underline dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">Docs</a> for more information on Self-Hosted version.
|
<p className="text-sm font-medium">
|
||||||
</p>
|
SurfSense Cloud is currently in development. Check{" "}
|
||||||
</div>
|
<a
|
||||||
</motion.div>
|
href="/docs"
|
||||||
</motion.div>
|
className="text-blue-600 underline dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
||||||
|
>
|
||||||
<motion.button
|
Docs
|
||||||
whileHover={{ scale: 1.02 }}
|
</a>{" "}
|
||||||
whileTap={{ scale: 0.98 }}
|
for more information on Self-Hosted version.
|
||||||
className="group/btn relative flex w-full items-center justify-center space-x-2 rounded-lg bg-white px-6 py-4 text-neutral-700 shadow-lg transition-all duration-200 hover:shadow-xl dark:bg-neutral-800 dark:text-neutral-200"
|
</p>
|
||||||
onClick={handleGoogleLogin}
|
</div>
|
||||||
>
|
</motion.div>
|
||||||
<div className="absolute inset-0 h-full w-full transform opacity-0 transition duration-200 group-hover/btn:opacity-100">
|
</motion.div>
|
||||||
<div className="absolute -left-px -top-px h-4 w-4 rounded-tl-lg border-l-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-left-2 group-hover/btn:-top-2"></div>
|
|
||||||
<div className="absolute -right-px -top-px h-4 w-4 rounded-tr-lg border-r-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-right-2 group-hover/btn:-top-2"></div>
|
<motion.button
|
||||||
<div className="absolute -bottom-px -left-px h-4 w-4 rounded-bl-lg border-b-2 border-l-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-left-2"></div>
|
whileHover={{ scale: 1.02 }}
|
||||||
<div className="absolute -bottom-px -right-px h-4 w-4 rounded-br-lg border-b-2 border-r-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-right-2"></div>
|
whileTap={{ scale: 0.98 }}
|
||||||
</div>
|
className="group/btn relative flex w-full items-center justify-center space-x-2 rounded-lg bg-white px-6 py-4 text-neutral-700 shadow-lg transition-all duration-200 hover:shadow-xl dark:bg-neutral-800 dark:text-neutral-200"
|
||||||
<IconBrandGoogleFilled className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
|
onClick={handleGoogleLogin}
|
||||||
<span className="text-base font-medium">Continue with Google</span>
|
>
|
||||||
</motion.button>
|
<div className="absolute inset-0 h-full w-full transform opacity-0 transition duration-200 group-hover/btn:opacity-100">
|
||||||
</div>
|
<div className="absolute -left-px -top-px h-4 w-4 rounded-tl-lg border-l-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-left-2 group-hover/btn:-top-2"></div>
|
||||||
</div>
|
<div className="absolute -right-px -top-px h-4 w-4 rounded-tr-lg border-r-2 border-t-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-right-2 group-hover/btn:-top-2"></div>
|
||||||
);
|
<div className="absolute -bottom-px -left-px h-4 w-4 rounded-bl-lg border-b-2 border-l-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-left-2"></div>
|
||||||
}
|
<div className="absolute -bottom-px -right-px h-4 w-4 rounded-br-lg border-b-2 border-r-2 border-blue-500 bg-transparent transition-all duration-200 group-hover/btn:-bottom-2 group-hover/btn:-right-2"></div>
|
||||||
|
</div>
|
||||||
|
<IconBrandGoogleFilled className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
|
||||||
|
<span className="text-base font-medium">Continue with Google</span>
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,114 +1,124 @@
|
||||||
"use client";
|
"use client";
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export function LocalLoginForm() {
|
export function LocalLoginForm() {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [authType, setAuthType] = useState<string | null>(null);
|
const [authType, setAuthType] = useState<string | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Get the auth type from environment variables
|
// Get the auth type from environment variables
|
||||||
setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE");
|
setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create form data for the API request
|
// Create form data for the API request
|
||||||
const formData = new URLSearchParams();
|
const formData = new URLSearchParams();
|
||||||
formData.append("username", username);
|
formData.append("username", username);
|
||||||
formData.append("password", password);
|
formData.append("password", password);
|
||||||
formData.append("grant_type", "password");
|
formData.append("grant_type", "password");
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/login`,
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/jwt/login`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
},
|
},
|
||||||
body: formData.toString(),
|
body: formData.toString(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.detail || "Failed to login");
|
throw new Error(data.detail || "Failed to login");
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push("/auth/callback?token=" + data.access_token);
|
router.push(`/auth/callback?token=${data.access_token}`);
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
setError(err.message || "An error occurred during login");
|
const errorMessage = err instanceof Error ? err.message : "An error occurred during login";
|
||||||
} finally {
|
setError(errorMessage);
|
||||||
setIsLoading(false);
|
} finally {
|
||||||
}
|
setIsLoading(false);
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
|
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label
|
||||||
Password
|
htmlFor="email"
|
||||||
</label>
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
<input
|
>
|
||||||
id="password"
|
Email
|
||||||
type="password"
|
</label>
|
||||||
required
|
<input
|
||||||
value={password}
|
id="email"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
type="email"
|
||||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
required
|
||||||
/>
|
value={username}
|
||||||
</div>
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<div>
|
||||||
type="submit"
|
<label
|
||||||
disabled={isLoading}
|
htmlFor="password"
|
||||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
{isLoading ? "Signing in..." : "Sign in"}
|
Password
|
||||||
</button>
|
</label>
|
||||||
</form>
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{authType === "LOCAL" && (
|
<button
|
||||||
<div className="mt-4 text-center text-sm">
|
type="submit"
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
disabled={isLoading}
|
||||||
Don't have an account?{" "}
|
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
>
|
||||||
Register here
|
{isLoading ? "Signing in..." : "Sign in"}
|
||||||
</Link>
|
</button>
|
||||||
</p>
|
</form>
|
||||||
</div>
|
|
||||||
)}
|
{authType === "LOCAL" && (
|
||||||
</div>
|
<div className="mt-4 text-center text-sm">
|
||||||
);
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
}
|
Don'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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from "react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { GoogleLoginButton } from "./GoogleLoginButton";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { LocalLoginForm } from "./LocalLoginForm";
|
import { Suspense, useEffect, useState } from "react";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { AmbientBackground } from "./AmbientBackground";
|
import { AmbientBackground } from "./AmbientBackground";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { GoogleLoginButton } from "./GoogleLoginButton";
|
||||||
import { Loader2 } from "lucide-react";
|
import { LocalLoginForm } from "./LocalLoginForm";
|
||||||
|
|
||||||
function LoginContent() {
|
function LoginContent() {
|
||||||
const [authType, setAuthType] = useState<string | null>(null);
|
const [authType, setAuthType] = useState<string | null>(null);
|
||||||
const [registrationSuccess, setRegistrationSuccess] = useState(false);
|
const [registrationSuccess, setRegistrationSuccess] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if the user was redirected from registration
|
// Check if the user was redirected from registration
|
||||||
if (searchParams.get("registered") === "true") {
|
if (searchParams.get("registered") === "true") {
|
||||||
setRegistrationSuccess(true);
|
setRegistrationSuccess(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the auth type from environment variables
|
// Get the auth type from environment variables
|
||||||
setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE");
|
setAuthType(process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE");
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
// Show loading state while determining auth type
|
// Show loading state while determining auth type
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full overflow-hidden">
|
<div className="relative w-full overflow-hidden">
|
||||||
<AmbientBackground />
|
<AmbientBackground />
|
||||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||||
<Logo className="rounded-md" />
|
<Logo className="rounded-md" />
|
||||||
<div className="mt-8 flex items-center space-x-2">
|
<div className="mt-8 flex items-center space-x-2">
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
<span className="text-muted-foreground">Loading...</span>
|
<span className="text-muted-foreground">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authType === "GOOGLE") {
|
if (authType === "GOOGLE") {
|
||||||
return <GoogleLoginButton />;
|
return <GoogleLoginButton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full overflow-hidden">
|
<div className="relative w-full overflow-hidden">
|
||||||
<AmbientBackground />
|
<AmbientBackground />
|
||||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||||
<Logo className="rounded-md" />
|
<Logo className="rounded-md" />
|
||||||
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
|
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
|
||||||
Sign In
|
Sign In
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{registrationSuccess && (
|
{registrationSuccess && (
|
||||||
<div className="mb-4 w-full rounded-md bg-green-50 p-4 text-sm text-green-500 dark:bg-green-900/20 dark:text-green-200">
|
<div className="mb-4 w-full rounded-md bg-green-50 p-4 text-sm text-green-500 dark:bg-green-900/20 dark:text-green-200">
|
||||||
Registration successful! You can now sign in with your credentials.
|
Registration successful! You can now sign in with your credentials.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<LocalLoginForm />
|
<LocalLoginForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading fallback for Suspense
|
// Loading fallback for Suspense
|
||||||
const LoadingFallback = () => (
|
const LoadingFallback = () => (
|
||||||
<div className="relative w-full overflow-hidden">
|
<div className="relative w-full overflow-hidden">
|
||||||
<AmbientBackground />
|
<AmbientBackground />
|
||||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||||
<Logo className="rounded-md" />
|
<Logo className="rounded-md" />
|
||||||
<div className="mt-8 flex items-center space-x-2">
|
<div className="mt-8 flex items-center space-x-2">
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
<span className="text-muted-foreground">Loading...</span>
|
<span className="text-muted-foreground">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<LoginContent />
|
<LoginContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,227 +1,238 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { useRouter } from 'next/navigation';
|
import { ArrowLeft, ArrowRight, Bot, CheckCircle, Sparkles } from "lucide-react";
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from '@/components/ui/button';
|
import { useEffect, useState } from "react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Logo } from "@/components/Logo";
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { AddProviderStep } from "@/components/onboard/add-provider-step";
|
||||||
import { CheckCircle, ArrowRight, ArrowLeft, Bot, Sparkles, Zap, Brain } from 'lucide-react';
|
import { AssignRolesStep } from "@/components/onboard/assign-roles-step";
|
||||||
import { Logo } from '@/components/Logo';
|
import { CompletionStep } from "@/components/onboard/completion-step";
|
||||||
import { useLLMConfigs, useLLMPreferences } from '@/hooks/use-llm-configs';
|
import { Button } from "@/components/ui/button";
|
||||||
import { AddProviderStep } from '@/components/onboard/add-provider-step';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { AssignRolesStep } from '@/components/onboard/assign-roles-step';
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { CompletionStep } from '@/components/onboard/completion-step';
|
import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||||
|
|
||||||
const TOTAL_STEPS = 3;
|
const TOTAL_STEPS = 3;
|
||||||
|
|
||||||
const OnboardPage = () => {
|
const OnboardPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { llmConfigs, loading: configsLoading, refreshConfigs } = useLLMConfigs();
|
const { llmConfigs, loading: configsLoading, refreshConfigs } = useLLMConfigs();
|
||||||
const { preferences, loading: preferencesLoading, isOnboardingComplete, refreshPreferences } = useLLMPreferences();
|
const {
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
preferences,
|
||||||
const [hasUserProgressed, setHasUserProgressed] = useState(false);
|
loading: preferencesLoading,
|
||||||
|
isOnboardingComplete,
|
||||||
|
refreshPreferences,
|
||||||
|
} = useLLMPreferences();
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
const [hasUserProgressed, setHasUserProgressed] = useState(false);
|
||||||
|
|
||||||
// Check if user is authenticated
|
// Check if user is authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('surfsense_bearer_token');
|
const token = localStorage.getItem("surfsense_bearer_token");
|
||||||
if (!token) {
|
if (!token) {
|
||||||
router.push('/login');
|
router.push("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
// Track if user has progressed beyond step 1
|
// Track if user has progressed beyond step 1
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentStep > 1) {
|
if (currentStep > 1) {
|
||||||
setHasUserProgressed(true);
|
setHasUserProgressed(true);
|
||||||
}
|
}
|
||||||
}, [currentStep]);
|
}, [currentStep]);
|
||||||
|
|
||||||
// Redirect to dashboard if onboarding is already complete and user hasn't progressed (fresh page load)
|
// Redirect to dashboard if onboarding is already complete and user hasn't progressed (fresh page load)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!preferencesLoading && isOnboardingComplete() && !hasUserProgressed) {
|
if (!preferencesLoading && isOnboardingComplete() && !hasUserProgressed) {
|
||||||
router.push('/dashboard');
|
router.push("/dashboard");
|
||||||
}
|
}
|
||||||
}, [preferencesLoading, isOnboardingComplete, hasUserProgressed, router]);
|
}, [preferencesLoading, isOnboardingComplete, hasUserProgressed, router]);
|
||||||
|
|
||||||
|
const progress = (currentStep / TOTAL_STEPS) * 100;
|
||||||
|
|
||||||
|
const stepTitles = ["Add LLM Provider", "Assign LLM Roles", "Setup Complete"];
|
||||||
|
|
||||||
const progress = (currentStep / TOTAL_STEPS) * 100;
|
const stepDescriptions = [
|
||||||
|
"Configure your first model provider",
|
||||||
|
"Assign specific roles to your LLM configurations",
|
||||||
|
"You're all set to start using SurfSense!",
|
||||||
|
];
|
||||||
|
|
||||||
const stepTitles = [
|
const canProceedToStep2 = !configsLoading && llmConfigs.length > 0;
|
||||||
"Add LLM Provider",
|
const canProceedToStep3 =
|
||||||
"Assign LLM Roles",
|
!preferencesLoading &&
|
||||||
"Setup Complete"
|
preferences.long_context_llm_id &&
|
||||||
];
|
preferences.fast_llm_id &&
|
||||||
|
preferences.strategic_llm_id;
|
||||||
|
|
||||||
const stepDescriptions = [
|
const handleNext = () => {
|
||||||
"Configure your first model provider",
|
if (currentStep < TOTAL_STEPS) {
|
||||||
"Assign specific roles to your LLM configurations",
|
setCurrentStep(currentStep + 1);
|
||||||
"You're all set to start using SurfSense!"
|
}
|
||||||
];
|
};
|
||||||
|
|
||||||
const canProceedToStep2 = !configsLoading && llmConfigs.length > 0;
|
const handlePrevious = () => {
|
||||||
const canProceedToStep3 = !preferencesLoading && preferences.long_context_llm_id && preferences.fast_llm_id && preferences.strategic_llm_id;
|
if (currentStep > 1) {
|
||||||
|
setCurrentStep(currentStep - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = () => {
|
||||||
|
router.push("/dashboard");
|
||||||
|
};
|
||||||
|
|
||||||
const handleNext = () => {
|
if (configsLoading || preferencesLoading) {
|
||||||
if (currentStep < TOTAL_STEPS) {
|
return (
|
||||||
setCurrentStep(currentStep + 1);
|
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||||
}
|
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||||
};
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Bot className="h-12 w-12 text-primary animate-pulse mb-4" />
|
||||||
|
<p className="text-sm text-muted-foreground">Loading your configuration...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handlePrevious = () => {
|
return (
|
||||||
if (currentStep > 1) {
|
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/20 flex items-center justify-center p-4">
|
||||||
setCurrentStep(currentStep - 1);
|
<motion.div
|
||||||
}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
};
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="w-full max-w-4xl"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="flex items-center justify-center mb-4">
|
||||||
|
<Logo className="w-12 h-12 mr-3" />
|
||||||
|
<h1 className="text-3xl font-bold">Welcome to SurfSense</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-lg">
|
||||||
|
Let's configure your SurfSense to get started
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
const handleComplete = () => {
|
{/* Progress */}
|
||||||
router.push('/dashboard');
|
<Card className="mb-8 bg-background/60 backdrop-blur-sm">
|
||||||
};
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
Step {currentStep} of {TOTAL_STEPS}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{Math.round(progress)}% Complete</div>
|
||||||
|
</div>
|
||||||
|
<Progress value={progress} className="mb-4" />
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{Array.from({ length: TOTAL_STEPS }, (_, i) => {
|
||||||
|
const stepNum = i + 1;
|
||||||
|
const isCompleted = stepNum < currentStep;
|
||||||
|
const isCurrent = stepNum === currentStep;
|
||||||
|
|
||||||
if (configsLoading || preferencesLoading) {
|
return (
|
||||||
return (
|
<div key={stepNum} className="flex items-center space-x-2">
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
<div
|
||||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
isCompleted
|
||||||
<Bot className="h-12 w-12 text-primary animate-pulse mb-4" />
|
? "bg-primary text-primary-foreground"
|
||||||
<p className="text-sm text-muted-foreground">Loading your configuration...</p>
|
: isCurrent
|
||||||
</CardContent>
|
? "bg-primary/20 text-primary border-2 border-primary"
|
||||||
</Card>
|
: "bg-muted text-muted-foreground"
|
||||||
</div>
|
}`}
|
||||||
);
|
>
|
||||||
}
|
{isCompleted ? <CheckCircle className="w-4 h-4" /> : stepNum}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
className={`text-sm font-medium truncate ${
|
||||||
|
isCurrent ? "text-foreground" : "text-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{stepTitles[i]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
return (
|
{/* Step Content */}
|
||||||
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/20 flex items-center justify-center p-4">
|
<Card className="min-h-[500px] bg-background/60 backdrop-blur-sm">
|
||||||
<motion.div
|
<CardHeader className="text-center">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<CardTitle className="text-2xl flex items-center justify-center gap-2">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
{currentStep === 1 && <Bot className="w-6 h-6" />}
|
||||||
transition={{ duration: 0.5 }}
|
{currentStep === 2 && <Sparkles className="w-6 h-6" />}
|
||||||
className="w-full max-w-4xl"
|
{currentStep === 3 && <CheckCircle className="w-6 h-6" />}
|
||||||
>
|
{stepTitles[currentStep - 1]}
|
||||||
{/* Header */}
|
</CardTitle>
|
||||||
<div className="text-center mb-8">
|
<CardDescription className="text-base">
|
||||||
<div className="flex items-center justify-center mb-4">
|
{stepDescriptions[currentStep - 1]}
|
||||||
<Logo className="w-12 h-12 mr-3" />
|
</CardDescription>
|
||||||
<h1 className="text-3xl font-bold">Welcome to SurfSense</h1>
|
</CardHeader>
|
||||||
</div>
|
<CardContent>
|
||||||
<p className="text-muted-foreground text-lg">Let's configure your SurfSense to get started</p>
|
<AnimatePresence mode="wait">
|
||||||
</div>
|
<motion.div
|
||||||
|
key={currentStep}
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<AddProviderStep
|
||||||
|
onConfigCreated={refreshConfigs}
|
||||||
|
onConfigDeleted={refreshConfigs}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStep === 2 && <AssignRolesStep onPreferencesUpdated={refreshPreferences} />}
|
||||||
|
{currentStep === 3 && <CompletionStep />}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Progress */}
|
{/* Navigation */}
|
||||||
<Card className="mb-8 bg-background/60 backdrop-blur-sm">
|
<div className="flex justify-between mt-8">
|
||||||
<CardContent className="pt-6">
|
<Button
|
||||||
<div className="flex items-center justify-between mb-4">
|
variant="outline"
|
||||||
<div className="text-sm font-medium">Step {currentStep} of {TOTAL_STEPS}</div>
|
onClick={handlePrevious}
|
||||||
<div className="text-sm text-muted-foreground">{Math.round(progress)}% Complete</div>
|
disabled={currentStep === 1}
|
||||||
</div>
|
className="flex items-center gap-2"
|
||||||
<Progress value={progress} className="mb-4" />
|
>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<ArrowLeft className="w-4 h-4" />
|
||||||
{Array.from({ length: TOTAL_STEPS }, (_, i) => {
|
Previous
|
||||||
const stepNum = i + 1;
|
</Button>
|
||||||
const isCompleted = stepNum < currentStep;
|
|
||||||
const isCurrent = stepNum === currentStep;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={stepNum} className="flex items-center space-x-2">
|
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
|
||||||
isCompleted
|
|
||||||
? 'bg-primary text-primary-foreground'
|
|
||||||
: isCurrent
|
|
||||||
? 'bg-primary/20 text-primary border-2 border-primary'
|
|
||||||
: 'bg-muted text-muted-foreground'
|
|
||||||
}`}>
|
|
||||||
{isCompleted ? <CheckCircle className="w-4 h-4" /> : stepNum}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className={`text-sm font-medium truncate ${
|
|
||||||
isCurrent ? 'text-foreground' : 'text-muted-foreground'
|
|
||||||
}`}>
|
|
||||||
{stepTitles[i]}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Step Content */}
|
<div className="flex gap-2">
|
||||||
<Card className="min-h-[500px] bg-background/60 backdrop-blur-sm">
|
{currentStep < TOTAL_STEPS && (
|
||||||
<CardHeader className="text-center">
|
<Button
|
||||||
<CardTitle className="text-2xl flex items-center justify-center gap-2">
|
onClick={handleNext}
|
||||||
{currentStep === 1 && <Bot className="w-6 h-6" />}
|
disabled={
|
||||||
{currentStep === 2 && <Sparkles className="w-6 h-6" />}
|
(currentStep === 1 && !canProceedToStep2) ||
|
||||||
{currentStep === 3 && <CheckCircle className="w-6 h-6" />}
|
(currentStep === 2 && !canProceedToStep3)
|
||||||
{stepTitles[currentStep - 1]}
|
}
|
||||||
</CardTitle>
|
className="flex items-center gap-2"
|
||||||
<CardDescription className="text-base">
|
>
|
||||||
{stepDescriptions[currentStep - 1]}
|
Next
|
||||||
</CardDescription>
|
<ArrowRight className="w-4 h-4" />
|
||||||
</CardHeader>
|
</Button>
|
||||||
<CardContent>
|
)}
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<motion.div
|
|
||||||
key={currentStep}
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: -20 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
>
|
|
||||||
{currentStep === 1 && <AddProviderStep onConfigCreated={refreshConfigs} onConfigDeleted={refreshConfigs} />}
|
|
||||||
{currentStep === 2 && <AssignRolesStep onPreferencesUpdated={refreshPreferences} />}
|
|
||||||
{currentStep === 3 && <CompletionStep />}
|
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
{currentStep === TOTAL_STEPS && (
|
||||||
<div className="flex justify-between mt-8">
|
<Button onClick={handleComplete} className="flex items-center gap-2">
|
||||||
<Button
|
Complete Setup
|
||||||
variant="outline"
|
<CheckCircle className="w-4 h-4" />
|
||||||
onClick={handlePrevious}
|
</Button>
|
||||||
disabled={currentStep === 1}
|
)}
|
||||||
className="flex items-center gap-2"
|
</div>
|
||||||
>
|
</div>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
</motion.div>
|
||||||
Previous
|
</div>
|
||||||
</Button>
|
);
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{currentStep < TOTAL_STEPS && (
|
|
||||||
<Button
|
|
||||||
onClick={handleNext}
|
|
||||||
disabled={
|
|
||||||
(currentStep === 1 && !canProceedToStep2) ||
|
|
||||||
(currentStep === 2 && !canProceedToStep3)
|
|
||||||
}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
<ArrowRight className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentStep === TOTAL_STEPS && (
|
|
||||||
<Button
|
|
||||||
onClick={handleComplete}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
Complete Setup
|
|
||||||
<CheckCircle className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default OnboardPage;
|
export default OnboardPage;
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
"use client";
|
"use client";
|
||||||
import React from "react";
|
|
||||||
import { Navbar } from "@/components/Navbar";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { ModernHeroWithGradients } from "@/components/ModernHeroWithGradients";
|
|
||||||
import { Footer } from "@/components/Footer";
|
import { Footer } from "@/components/Footer";
|
||||||
|
import { ModernHeroWithGradients } from "@/components/ModernHeroWithGradients";
|
||||||
|
import { Navbar } from "@/components/Navbar";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white">
|
<main className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<ModernHeroWithGradients />
|
<ModernHeroWithGradients />
|
||||||
<Footer />
|
<Footer />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,146 +1,190 @@
|
||||||
import { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Privacy Policy | SurfSense",
|
title: "Privacy Policy | SurfSense",
|
||||||
description: "Privacy Policy for SurfSense application",
|
description: "Privacy Policy for SurfSense application",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PrivacyPolicy() {
|
export default function PrivacyPolicy() {
|
||||||
return (
|
return (
|
||||||
<div className="container max-w-4xl mx-auto py-12 px-4">
|
<div className="container max-w-4xl mx-auto py-12 px-4">
|
||||||
<h1 className="text-4xl font-bold mb-8">Privacy Policy</h1>
|
<h1 className="text-4xl font-bold mb-8">Privacy Policy</h1>
|
||||||
|
|
||||||
<div className="prose dark:prose-invert max-w-none">
|
|
||||||
<p className="text-lg mb-6">Last updated: {new Date().toLocaleDateString()}</p>
|
|
||||||
|
|
||||||
<section className="mb-8">
|
<div className="prose dark:prose-invert max-w-none">
|
||||||
<h2 className="text-2xl font-semibold mb-4">1. Introduction</h2>
|
<p className="text-lg mb-6">Last updated: {new Date().toLocaleDateString()}</p>
|
||||||
<p>
|
|
||||||
Welcome to SurfSense. We respect your privacy and are committed to protecting your personal data.
|
|
||||||
This privacy policy will inform you about how we look after your personal data when you visit our
|
|
||||||
website and tell you about your privacy rights and how the law protects you.
|
|
||||||
</p>
|
|
||||||
<p className="mt-4">
|
|
||||||
By using our services, you acknowledge that you have read and understood this Privacy Policy. We reserve
|
|
||||||
the right to modify this policy at any time, and such modifications shall be effective immediately upon
|
|
||||||
posting the modified policy on this website.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">2. Data We Collect</h2>
|
<h2 className="text-2xl font-semibold mb-4">1. Introduction</h2>
|
||||||
<p>
|
<p>
|
||||||
We may collect, use, store and transfer different kinds of personal data about you which we have
|
Welcome to SurfSense. We respect your privacy and are committed to protecting your
|
||||||
grouped together as follows:
|
personal data. This privacy policy will inform you about how we look after your personal
|
||||||
</p>
|
data when you visit our website and tell you about your privacy rights and how the law
|
||||||
<ul className="list-disc pl-6 my-4 space-y-2">
|
protects you.
|
||||||
<li><strong>Identity Data</strong> includes first name, last name, username or similar identifier.</li>
|
</p>
|
||||||
<li><strong>Contact Data</strong> includes email address and telephone numbers.</li>
|
<p className="mt-4">
|
||||||
<li><strong>Technical Data</strong> includes internet protocol (IP) address, your login data, browser type and version,
|
By using our services, you acknowledge that you have read and understood this Privacy
|
||||||
time zone setting and location, browser plug-in types and versions, operating system and platform,
|
Policy. We reserve the right to modify this policy at any time, and such modifications
|
||||||
and other technology on the devices you use to access this website.</li>
|
shall be effective immediately upon posting the modified policy on this website.
|
||||||
<li><strong>Usage Data</strong> includes information about how you use our website and services.</li>
|
</p>
|
||||||
<li><strong>Surf Data</strong> includes information about surf sessions, preferences, and equipment settings.</li>
|
</section>
|
||||||
<li><strong>Marketing and Communications Data</strong> includes your preferences in receiving marketing from us and your communication preferences.</li>
|
|
||||||
<li><strong>Aggregated Data</strong> which may be derived from your personal data but is not considered personal data as it does not directly or indirectly reveal your identity.</li>
|
|
||||||
</ul>
|
|
||||||
<p className="mt-4">
|
|
||||||
We may also collect, use and share Aggregated Data such as statistical or demographic data for any purpose.
|
|
||||||
Aggregated Data may be derived from your personal data but is not considered personal data in law as this data
|
|
||||||
does not directly or indirectly reveal your identity.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">3. How We Use Your Data</h2>
|
<h2 className="text-2xl font-semibold mb-4">2. Data We Collect</h2>
|
||||||
<p>
|
<p>
|
||||||
We will only use your personal data when the law allows us to. Most commonly, we will use your
|
We may collect, use, store and transfer different kinds of personal data about you which
|
||||||
personal data in the following circumstances:
|
we have grouped together as follows:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc pl-6 my-4 space-y-2">
|
<ul className="list-disc pl-6 my-4 space-y-2">
|
||||||
<li>Where we need to perform the contract we are about to enter into or have entered into with you.</li>
|
<li>
|
||||||
<li>Where it is necessary for our legitimate interests (or those of a third party) and your interests
|
<strong>Identity Data</strong> includes first name, last name, username or similar
|
||||||
and fundamental rights do not override those interests.</li>
|
identifier.
|
||||||
<li>Where we need to comply with a legal obligation.</li>
|
</li>
|
||||||
<li>To provide and maintain our services, including to monitor the usage of our service.</li>
|
<li>
|
||||||
<li>To improve our services, products, marketing, and customer relationships and experiences.</li>
|
<strong>Contact Data</strong> includes email address and telephone numbers.
|
||||||
<li>To communicate with you about updates, security alerts, and support messages.</li>
|
</li>
|
||||||
<li>To provide customer support and respond to your requests or inquiries.</li>
|
<li>
|
||||||
<li>For business transfers, such as in connection with a merger, sale of company assets, financing, or acquisition.</li>
|
<strong>Technical Data</strong> includes internet protocol (IP) address, your login
|
||||||
</ul>
|
data, browser type and version, time zone setting and location, browser plug-in types
|
||||||
<p className="mt-4">
|
and versions, operating system and platform, and other technology on the devices you
|
||||||
We may use your information for marketing purposes, such as sending you information about our products, services,
|
use to access this website.
|
||||||
promotions, and events. You can opt-out of receiving these communications at any time.
|
</li>
|
||||||
</p>
|
<li>
|
||||||
</section>
|
<strong>Usage Data</strong> includes information about how you use our website and
|
||||||
|
services.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Surf Data</strong> includes information about surf sessions, preferences, and
|
||||||
|
equipment settings.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Marketing and Communications Data</strong> includes your preferences in
|
||||||
|
receiving marketing from us and your communication preferences.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Aggregated Data</strong> which may be derived from your personal data but is
|
||||||
|
not considered personal data as it does not directly or indirectly reveal your
|
||||||
|
identity.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-4">
|
||||||
|
We may also collect, use and share Aggregated Data such as statistical or demographic
|
||||||
|
data for any purpose. Aggregated Data may be derived from your personal data but is not
|
||||||
|
considered personal data in law as this data does not directly or indirectly reveal your
|
||||||
|
identity.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">4. Data Security</h2>
|
<h2 className="text-2xl font-semibold mb-4">3. How We Use Your Data</h2>
|
||||||
<p>
|
<p>
|
||||||
We have put in place appropriate security measures to prevent your personal data from being
|
We will only use your personal data when the law allows us to. Most commonly, we will
|
||||||
accidentally lost, used or accessed in an unauthorized way, altered or disclosed. In addition,
|
use your personal data in the following circumstances:
|
||||||
we limit access to your personal data to those employees, agents, contractors and other third
|
</p>
|
||||||
parties who have a business need to know.
|
<ul className="list-disc pl-6 my-4 space-y-2">
|
||||||
</p>
|
<li>
|
||||||
<p className="mt-4">
|
Where we need to perform the contract we are about to enter into or have entered into
|
||||||
While we implement safeguards designed to protect your information, no security system is impenetrable
|
with you.
|
||||||
and due to the inherent nature of the Internet, we cannot guarantee that information, during transmission
|
</li>
|
||||||
through the Internet or while stored on our systems, is absolutely safe from intrusion by others.
|
<li>
|
||||||
</p>
|
Where it is necessary for our legitimate interests (or those of a third party) and
|
||||||
</section>
|
your interests and fundamental rights do not override those interests.
|
||||||
|
</li>
|
||||||
|
<li>Where we need to comply with a legal obligation.</li>
|
||||||
|
<li>
|
||||||
|
To provide and maintain our services, including to monitor the usage of our service.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
To improve our services, products, marketing, and customer relationships and
|
||||||
|
experiences.
|
||||||
|
</li>
|
||||||
|
<li>To communicate with you about updates, security alerts, and support messages.</li>
|
||||||
|
<li>To provide customer support and respond to your requests or inquiries.</li>
|
||||||
|
<li>
|
||||||
|
For business transfers, such as in connection with a merger, sale of company assets,
|
||||||
|
financing, or acquisition.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-4">
|
||||||
|
We may use your information for marketing purposes, such as sending you information
|
||||||
|
about our products, services, promotions, and events. You can opt-out of receiving these
|
||||||
|
communications at any time.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">5. Data Retention</h2>
|
<h2 className="text-2xl font-semibold mb-4">4. Data Security</h2>
|
||||||
<p>
|
<p>
|
||||||
We will only retain your personal data for as long as necessary to fulfill the purposes we collected it for,
|
We have put in place appropriate security measures to prevent your personal data from
|
||||||
including for the purposes of satisfying any legal, accounting, or reporting requirements. To determine the appropriate
|
being accidentally lost, used or accessed in an unauthorized way, altered or disclosed.
|
||||||
retention period for personal data, we consider the amount, nature, and sensitivity of the personal data, the
|
In addition, we limit access to your personal data to those employees, agents,
|
||||||
potential risk of harm from unauthorized use or disclosure of your personal data, the purposes for which we process
|
contractors and other third parties who have a business need to know.
|
||||||
your personal data and whether we can achieve those purposes through other means, and the applicable legal requirements.
|
</p>
|
||||||
</p>
|
<p className="mt-4">
|
||||||
</section>
|
While we implement safeguards designed to protect your information, no security system
|
||||||
|
is impenetrable and due to the inherent nature of the Internet, we cannot guarantee that
|
||||||
|
information, during transmission through the Internet or while stored on our systems, is
|
||||||
|
absolutely safe from intrusion by others.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">6. Your Legal Rights</h2>
|
<h2 className="text-2xl font-semibold mb-4">5. Data Retention</h2>
|
||||||
<p>
|
<p>
|
||||||
Under certain circumstances, you have rights under data protection laws in relation to your personal data, including:
|
We will only retain your personal data for as long as necessary to fulfill the purposes
|
||||||
</p>
|
we collected it for, including for the purposes of satisfying any legal, accounting, or
|
||||||
<ul className="list-disc pl-6 my-4 space-y-2">
|
reporting requirements. To determine the appropriate retention period for personal data,
|
||||||
<li>The right to request access to your personal data.</li>
|
we consider the amount, nature, and sensitivity of the personal data, the potential risk
|
||||||
<li>The right to request correction of your personal data.</li>
|
of harm from unauthorized use or disclosure of your personal data, the purposes for
|
||||||
<li>The right to request erasure of your personal data.</li>
|
which we process your personal data and whether we can achieve those purposes through
|
||||||
<li>The right to object to processing of your personal data.</li>
|
other means, and the applicable legal requirements.
|
||||||
<li>The right to request restriction of processing your personal data.</li>
|
</p>
|
||||||
<li>The right to request transfer of your personal data.</li>
|
</section>
|
||||||
<li>The right to withdraw consent.</li>
|
|
||||||
</ul>
|
|
||||||
<p className="mt-4">
|
|
||||||
Please note that these rights are not absolute, and we may be entitled to refuse requests where exceptions apply.
|
|
||||||
If you wish to exercise any of the rights set out above, please contact us. We may need to request specific
|
|
||||||
information from you to help us confirm your identity and ensure your right to access your personal data.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">7. Third-Party Services</h2>
|
<h2 className="text-2xl font-semibold mb-4">6. Your Legal Rights</h2>
|
||||||
<p>
|
<p>
|
||||||
Our service may contain links to other websites that are not operated by us. If you click on a third-party link,
|
Under certain circumstances, you have rights under data protection laws in relation to
|
||||||
you will be directed to that third party's site. We strongly advise you to review the Privacy Policy of every site you visit.
|
your personal data, including:
|
||||||
We have no control over and assume no responsibility for the content, privacy policies, or practices of any third-party
|
</p>
|
||||||
sites or services.
|
<ul className="list-disc pl-6 my-4 space-y-2">
|
||||||
</p>
|
<li>The right to request access to your personal data.</li>
|
||||||
</section>
|
<li>The right to request correction of your personal data.</li>
|
||||||
|
<li>The right to request erasure of your personal data.</li>
|
||||||
|
<li>The right to object to processing of your personal data.</li>
|
||||||
|
<li>The right to request restriction of processing your personal data.</li>
|
||||||
|
<li>The right to request transfer of your personal data.</li>
|
||||||
|
<li>The right to withdraw consent.</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-4">
|
||||||
|
Please note that these rights are not absolute, and we may be entitled to refuse
|
||||||
|
requests where exceptions apply. If you wish to exercise any of the rights set out
|
||||||
|
above, please contact us. We may need to request specific information from you to help
|
||||||
|
us confirm your identity and ensure your right to access your personal data.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">8. Contact Us</h2>
|
<h2 className="text-2xl font-semibold mb-4">7. Third-Party Services</h2>
|
||||||
<p>
|
<p>
|
||||||
If you have any questions about this privacy policy or our privacy practices, please contact us at:
|
Our service may contain links to other websites that are not operated by us. If you
|
||||||
</p>
|
click on a third-party link, you will be directed to that third party's site. We
|
||||||
<p className="mt-2">
|
strongly advise you to review the Privacy Policy of every site you visit. We have no
|
||||||
<strong>Email:</strong> vermarohanfinal@gmail.com
|
control over and assume no responsibility for the content, privacy policies, or
|
||||||
</p>
|
practices of any third-party sites or services.
|
||||||
</section>
|
</p>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
);
|
<section className="mb-8">
|
||||||
}
|
<h2 className="text-2xl font-semibold mb-4">8. Contact Us</h2>
|
||||||
|
<p>
|
||||||
|
If you have any questions about this privacy policy or our privacy practices, please
|
||||||
|
contact us at:
|
||||||
|
</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
<strong>Email:</strong> vermarohanfinal@gmail.com
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,149 +1,161 @@
|
||||||
"use client";
|
"use client";
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { AmbientBackground } from "../login/AmbientBackground";
|
import { AmbientBackground } from "../login/AmbientBackground";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Check authentication type and redirect if not LOCAL
|
// Check authentication type and redirect if not LOCAL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const authType = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE";
|
const authType = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "GOOGLE";
|
||||||
if (authType !== "LOCAL") {
|
if (authType !== "LOCAL") {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Form validation
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
setError("Passwords do not match");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
// Form validation
|
||||||
setError("");
|
if (password !== confirmPassword) {
|
||||||
|
setError("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
setIsLoading(true);
|
||||||
const response = await fetch(
|
setError("");
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
is_active: true,
|
|
||||||
is_superuser: false,
|
|
||||||
is_verified: false,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = await response.json();
|
try {
|
||||||
|
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
is_active: true,
|
||||||
|
is_superuser: false,
|
||||||
|
is_verified: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
const data = await response.json();
|
||||||
throw new Error(data.detail || "Registration failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to login page after successful registration
|
if (!response.ok) {
|
||||||
router.push("/login?registered=true");
|
throw new Error(data.detail || "Registration failed");
|
||||||
} catch (err: any) {
|
}
|
||||||
setError(err.message || "An error occurred during registration");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
// Redirect to login page after successful registration
|
||||||
<div className="relative w-full overflow-hidden">
|
router.push("/login?registered=true");
|
||||||
<AmbientBackground />
|
} catch (err: unknown) {
|
||||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
const errorMessage =
|
||||||
<Logo className="rounded-md" />
|
err instanceof Error ? err.message : "An error occurred during registration";
|
||||||
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
|
setError(errorMessage);
|
||||||
Create an Account
|
} finally {
|
||||||
</h1>
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
<div className="w-full max-w-md">
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<div className="relative w-full overflow-hidden">
|
||||||
{error && (
|
<AmbientBackground />
|
||||||
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
|
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||||
{error}
|
<Logo className="rounded-md" />
|
||||||
</div>
|
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
|
||||||
)}
|
Create an Account
|
||||||
|
</h1>
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div className="w-full max-w-md">
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
Password
|
{error && (
|
||||||
</label>
|
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
|
||||||
<input
|
{error}
|
||||||
id="password"
|
</div>
|
||||||
type="password"
|
)}
|
||||||
required
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label
|
||||||
Confirm Password
|
htmlFor="email"
|
||||||
</label>
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
<input
|
>
|
||||||
id="confirmPassword"
|
Email
|
||||||
type="password"
|
</label>
|
||||||
required
|
<input
|
||||||
value={confirmPassword}
|
id="email"
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
type="email"
|
||||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
required
|
||||||
/>
|
value={email}
|
||||||
</div>
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<div>
|
||||||
type="submit"
|
<label
|
||||||
disabled={isLoading}
|
htmlFor="password"
|
||||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
{isLoading ? "Creating account..." : "Register"}
|
Password
|
||||||
</button>
|
</label>
|
||||||
</form>
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 text-center text-sm">
|
<div>
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
<label
|
||||||
Already have an account?{" "}
|
htmlFor="confirmPassword"
|
||||||
<Link href="/login" className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">
|
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||||
Sign in
|
>
|
||||||
</Link>
|
Confirm Password
|
||||||
</p>
|
</label>
|
||||||
</div>
|
<input
|
||||||
</div>
|
id="confirmPassword"
|
||||||
</div>
|
type="password"
|
||||||
</div>
|
required
|
||||||
);
|
value={confirmPassword}
|
||||||
}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? "Creating account..." : "Register"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center text-sm">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,71 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import { ArrowLeft, Bot, Brain, Settings } from "lucide-react"; // Import ArrowLeft icon
|
||||||
import { useRouter } from 'next/navigation'; // Add this import
|
import { useRouter } from "next/navigation"; // Add this import
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { ModelConfigManager } from "@/components/settings/model-config-manager";
|
||||||
import { Bot, Settings, Brain, ArrowLeft } from 'lucide-react'; // Import ArrowLeft icon
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { ModelConfigManager } from '@/components/settings/model-config-manager';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { LLMRoleManager } from '@/components/settings/llm-role-manager';
|
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const router = useRouter(); // Initialize router
|
const router = useRouter(); // Initialize router
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<div className="container max-w-7xl mx-auto p-6 lg:p-8">
|
<div className="container max-w-7xl mx-auto p-6 lg:p-8">
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/dashboard')}
|
onClick={() => router.push("/dashboard")}
|
||||||
className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 hover:bg-primary/20 transition-colors"
|
className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 hover:bg-primary/20 transition-colors"
|
||||||
aria-label="Back to Dashboard"
|
aria-label="Back to Dashboard"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-5 w-5 text-primary" />
|
<ArrowLeft className="h-5 w-5 text-primary" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
|
||||||
<Settings className="h-6 w-6 text-primary" />
|
<Settings className="h-6 w-6 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
|
||||||
<p className="text-lg text-muted-foreground">
|
<p className="text-lg text-muted-foreground">
|
||||||
Manage your LLM configurations and role assignments.
|
Manage your LLM configurations and role assignments.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-6" />
|
<Separator className="my-6" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Settings Content */}
|
{/* Settings Content */}
|
||||||
<Tabs defaultValue="models" className="space-y-8">
|
<Tabs defaultValue="models" className="space-y-8">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<TabsList className="grid w-full min-w-fit grid-cols-2 lg:w-auto lg:inline-grid">
|
<TabsList className="grid w-full min-w-fit grid-cols-2 lg:w-auto lg:inline-grid">
|
||||||
<TabsTrigger value="models" className="flex items-center gap-2 text-sm">
|
<TabsTrigger value="models" className="flex items-center gap-2 text-sm">
|
||||||
<Bot className="h-4 w-4" />
|
<Bot className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Model Configs</span>
|
<span className="hidden sm:inline">Model Configs</span>
|
||||||
<span className="sm:hidden">Models</span>
|
<span className="sm:hidden">Models</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="roles" className="flex items-center gap-2 text-sm">
|
<TabsTrigger value="roles" className="flex items-center gap-2 text-sm">
|
||||||
<Brain className="h-4 w-4" />
|
<Brain className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">LLM Roles</span>
|
<span className="hidden sm:inline">LLM Roles</span>
|
||||||
<span className="sm:hidden">Roles</span>
|
<span className="sm:hidden">Roles</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="models" className="space-y-6">
|
<TabsContent value="models" className="space-y-6">
|
||||||
<ModelConfigManager />
|
<ModelConfigManager />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="roles" className="space-y-6">
|
<TabsContent value="roles" className="space-y-6">
|
||||||
<LLMRoleManager />
|
<LLMRoleManager />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,48 @@
|
||||||
import type { MetadataRoute } from 'next'
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
url: 'https://www.surfsense.net/',
|
url: "https://www.surfsense.net/",
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: 'yearly',
|
changeFrequency: "yearly",
|
||||||
priority: 1,
|
priority: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: 'https://www.surfsense.net/privacy',
|
url: "https://www.surfsense.net/privacy",
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: "monthly",
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: 'https://www.surfsense.net/terms',
|
url: "https://www.surfsense.net/terms",
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: 'monthly',
|
changeFrequency: "monthly",
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: 'https://www.surfsense.net/docs',
|
url: "https://www.surfsense.net/docs",
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: 'weekly',
|
changeFrequency: "weekly",
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: 'https://www.surfsense.net/docs/installation',
|
url: "https://www.surfsense.net/docs/installation",
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: 'weekly',
|
changeFrequency: "weekly",
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: 'https://www.surfsense.net/docs/docker-installation',
|
url: "https://www.surfsense.net/docs/docker-installation",
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: 'weekly',
|
changeFrequency: "weekly",
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: 'https://www.surfsense.net/docs/manual-installation',
|
url: "https://www.surfsense.net/docs/manual-installation",
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: 'weekly',
|
changeFrequency: "weekly",
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,204 +1,225 @@
|
||||||
import { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Terms of Service | SurfSense",
|
title: "Terms of Service | SurfSense",
|
||||||
description: "Terms of Service for SurfSense application",
|
description: "Terms of Service for SurfSense application",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TermsOfService() {
|
export default function TermsOfService() {
|
||||||
return (
|
return (
|
||||||
<div className="container max-w-4xl mx-auto py-12 px-4">
|
<div className="container max-w-4xl mx-auto py-12 px-4">
|
||||||
<h1 className="text-4xl font-bold mb-8">Terms of Service</h1>
|
<h1 className="text-4xl font-bold mb-8">Terms of Service</h1>
|
||||||
|
|
||||||
<div className="prose dark:prose-invert max-w-none">
|
|
||||||
<p className="text-lg mb-6">Last updated: {new Date().toLocaleDateString()}</p>
|
|
||||||
|
|
||||||
<section className="mb-8">
|
<div className="prose dark:prose-invert max-w-none">
|
||||||
<h2 className="text-2xl font-semibold mb-4">1. Introduction</h2>
|
<p className="text-lg mb-6">Last updated: {new Date().toLocaleDateString()}</p>
|
||||||
<p>
|
|
||||||
Welcome to SurfSense. These Terms of Service govern your access to and use of the SurfSense website and services.
|
|
||||||
By accessing or using our services, you agree to be bound by these Terms.
|
|
||||||
</p>
|
|
||||||
<p className="mt-4">
|
|
||||||
Please read these Terms carefully before using our Services. By using our Services, you agree that these Terms
|
|
||||||
will govern your relationship with us. If you do not agree to these Terms, please refrain from using our Services.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">2. Using Our Services</h2>
|
<h2 className="text-2xl font-semibold mb-4">1. Introduction</h2>
|
||||||
<p>
|
<p>
|
||||||
You must follow any policies made available to you within the Services. You may use our Services only as
|
Welcome to SurfSense. These Terms of Service govern your access to and use of the
|
||||||
permitted by law. We may suspend or stop providing our Services to you if you do not comply with our terms or
|
SurfSense website and services. By accessing or using our services, you agree to be
|
||||||
policies or if we are investigating suspected misconduct.
|
bound by these Terms.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-4">
|
<p className="mt-4">
|
||||||
Using our Services does not give you ownership of any intellectual property rights in our Services or the
|
Please read these Terms carefully before using our Services. By using our Services, you
|
||||||
content you access. You may not use content from our Services unless you obtain permission from its owner or
|
agree that these Terms will govern your relationship with us. If you do not agree to
|
||||||
are otherwise permitted by law.
|
these Terms, please refrain from using our Services.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-4">
|
</section>
|
||||||
We reserve the right to remove any content that we reasonably believe violates these Terms, infringes any
|
|
||||||
intellectual property right, is abusive, illegal, or otherwise objectionable.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">3. Your Account</h2>
|
<h2 className="text-2xl font-semibold mb-4">2. Using Our Services</h2>
|
||||||
<p>
|
<p>
|
||||||
To use some of our services, you may need to create an account. You are responsible for safeguarding the
|
You must follow any policies made available to you within the Services. You may use our
|
||||||
password that you use to access the services and for any activities or actions under your password.
|
Services only as permitted by law. We may suspend or stop providing our Services to you
|
||||||
</p>
|
if you do not comply with our terms or policies or if we are investigating suspected
|
||||||
<p className="mt-4">
|
misconduct.
|
||||||
You must provide accurate and complete information when creating your account. You agree to update your
|
</p>
|
||||||
information to keep it accurate and complete. You are responsible for maintaining the confidentiality of your
|
<p className="mt-4">
|
||||||
account and password, including restricting access to your computer and/or account.
|
Using our Services does not give you ownership of any intellectual property rights in
|
||||||
</p>
|
our Services or the content you access. You may not use content from our Services unless
|
||||||
<p className="mt-4">
|
you obtain permission from its owner or are otherwise permitted by law.
|
||||||
We reserve the right to refuse service, terminate accounts, remove or edit content, or cancel orders at
|
</p>
|
||||||
our sole discretion.
|
<p className="mt-4">
|
||||||
</p>
|
We reserve the right to remove any content that we reasonably believe violates these
|
||||||
</section>
|
Terms, infringes any intellectual property right, is abusive, illegal, or otherwise
|
||||||
|
objectionable.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">4. Privacy and Copyright Protection</h2>
|
<h2 className="text-2xl font-semibold mb-4">3. Your Account</h2>
|
||||||
<p>
|
<p>
|
||||||
Our privacy policies explain how we treat your personal data and protect your privacy when you use our
|
To use some of our services, you may need to create an account. You are responsible for
|
||||||
Services. By using our Services, you agree that SurfSense can use such data in accordance with our privacy policies.
|
safeguarding the password that you use to access the services and for any activities or
|
||||||
</p>
|
actions under your password.
|
||||||
<p className="mt-4">
|
</p>
|
||||||
We respond to notices of alleged copyright infringement and terminate accounts of repeat infringers according
|
<p className="mt-4">
|
||||||
to the process set out in applicable copyright laws.
|
You must provide accurate and complete information when creating your account. You agree
|
||||||
</p>
|
to update your information to keep it accurate and complete. You are responsible for
|
||||||
</section>
|
maintaining the confidentiality of your account and password, including restricting
|
||||||
|
access to your computer and/or account.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4">
|
||||||
|
We reserve the right to refuse service, terminate accounts, remove or edit content, or
|
||||||
|
cancel orders at our sole discretion.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">5. License and Intellectual Property</h2>
|
<h2 className="text-2xl font-semibold mb-4">4. Privacy and Copyright Protection</h2>
|
||||||
<p>
|
<p>
|
||||||
SurfSense gives you a personal, worldwide, royalty-free, non-assignable and non-exclusive license to use the
|
Our privacy policies explain how we treat your personal data and protect your privacy
|
||||||
software provided to you as part of the Services. This license is for the sole purpose of enabling you to use
|
when you use our Services. By using our Services, you agree that SurfSense can use such
|
||||||
and enjoy the benefit of the Services as provided by SurfSense, in the manner permitted by these terms.
|
data in accordance with our privacy policies.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-4">
|
<p className="mt-4">
|
||||||
All content included in or made available through our Services—such as text, graphics, logos, button icons,
|
We respond to notices of alleged copyright infringement and terminate accounts of repeat
|
||||||
images, audio clips, digital downloads, data compilations, and software—is the property of SurfSense or its
|
infringers according to the process set out in applicable copyright laws.
|
||||||
content suppliers and is protected by international copyright, trademark, and other intellectual property laws.
|
</p>
|
||||||
</p>
|
</section>
|
||||||
<p className="mt-4">
|
|
||||||
By submitting, posting, or displaying content on or through our Services, you grant us a worldwide, non-exclusive,
|
|
||||||
royalty-free license to use, reproduce, modify, adapt, publish, translate, create derivative works from, distribute,
|
|
||||||
and display such content in any media for the purpose of providing and improving our Services.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">6. Modifying and Terminating our Services</h2>
|
<h2 className="text-2xl font-semibold mb-4">5. License and Intellectual Property</h2>
|
||||||
<p>
|
<p>
|
||||||
We are constantly changing and improving our Services. We may add or remove functionalities or features, and
|
SurfSense gives you a personal, worldwide, royalty-free, non-assignable and
|
||||||
we may suspend or stop a Service altogether. You can stop using our Services at any time. SurfSense may also
|
non-exclusive license to use the software provided to you as part of the Services. This
|
||||||
stop providing Services to you, or add or create new limits on our Services at any time.
|
license is for the sole purpose of enabling you to use and enjoy the benefit of the
|
||||||
</p>
|
Services as provided by SurfSense, in the manner permitted by these terms.
|
||||||
<p className="mt-4">
|
</p>
|
||||||
We believe that you own your data and preserving your access to such data is important. If we discontinue a Service,
|
<p className="mt-4">
|
||||||
where reasonably possible, we will give you reasonable advance notice and a chance to get information out of that Service.
|
All content included in or made available through our Services—such as text, graphics,
|
||||||
</p>
|
logos, button icons, images, audio clips, digital downloads, data compilations, and
|
||||||
<p className="mt-4">
|
software—is the property of SurfSense or its content suppliers and is protected by
|
||||||
We reserve the right to modify these Terms at any time. If we make material changes to these Terms, we will notify
|
international copyright, trademark, and other intellectual property laws.
|
||||||
you by email or by posting a notice on our website before the changes become effective. Your continued use of our
|
</p>
|
||||||
Services after the effective date of such changes constitutes your acceptance of the modified Terms.
|
<p className="mt-4">
|
||||||
</p>
|
By submitting, posting, or displaying content on or through our Services, you grant us a
|
||||||
</section>
|
worldwide, non-exclusive, royalty-free license to use, reproduce, modify, adapt,
|
||||||
|
publish, translate, create derivative works from, distribute, and display such content
|
||||||
|
in any media for the purpose of providing and improving our Services.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">7. Warranties and Disclaimers</h2>
|
<h2 className="text-2xl font-semibold mb-4">6. Modifying and Terminating our Services</h2>
|
||||||
<p>
|
<p>
|
||||||
We provide our Services using a commercially reasonable level of skill and care and we hope that you will
|
We are constantly changing and improving our Services. We may add or remove
|
||||||
enjoy using them. But there are certain things that we don't promise about our Services.
|
functionalities or features, and we may suspend or stop a Service altogether. You can
|
||||||
</p>
|
stop using our Services at any time. SurfSense may also stop providing Services to you,
|
||||||
<p className="mt-4 uppercase font-bold">
|
or add or create new limits on our Services at any time.
|
||||||
OTHER THAN AS EXPRESSLY SET OUT IN THESE TERMS OR ADDITIONAL TERMS, NEITHER SURFSENSE NOR ITS SUPPLIERS OR DISTRIBUTORS
|
</p>
|
||||||
MAKE ANY SPECIFIC PROMISES ABOUT THE SERVICES. FOR EXAMPLE, WE DON'T MAKE ANY COMMITMENTS ABOUT THE CONTENT WITHIN THE
|
<p className="mt-4">
|
||||||
SERVICES, THE SPECIFIC FUNCTIONS OF THE SERVICES, OR THEIR RELIABILITY, AVAILABILITY, OR ABILITY TO MEET YOUR NEEDS.
|
We believe that you own your data and preserving your access to such data is important.
|
||||||
WE PROVIDE THE SERVICES "AS IS".
|
If we discontinue a Service, where reasonably possible, we will give you reasonable
|
||||||
</p>
|
advance notice and a chance to get information out of that Service.
|
||||||
<p className="mt-4 uppercase font-bold">
|
</p>
|
||||||
SOME JURISDICTIONS PROVIDE FOR CERTAIN WARRANTIES, LIKE THE IMPLIED WARRANTY OF MERCHANTABILITY, FITNESS FOR A
|
<p className="mt-4">
|
||||||
PARTICULAR PURPOSE, AND NON-INFRINGEMENT. TO THE EXTENT PERMITTED BY LAW, WE EXCLUDE ALL WARRANTIES.
|
We reserve the right to modify these Terms at any time. If we make material changes to
|
||||||
</p>
|
these Terms, we will notify you by email or by posting a notice on our website before
|
||||||
</section>
|
the changes become effective. Your continued use of our Services after the effective
|
||||||
|
date of such changes constitutes your acceptance of the modified Terms.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">8. Liability for our Services</h2>
|
<h2 className="text-2xl font-semibold mb-4">7. Warranties and Disclaimers</h2>
|
||||||
<p className="uppercase font-bold">
|
<p>
|
||||||
WHEN PERMITTED BY LAW, SURFSENSE, AND SURFSENSE'S SUPPLIERS AND DISTRIBUTORS, WILL NOT BE RESPONSIBLE FOR
|
We provide our Services using a commercially reasonable level of skill and care and we
|
||||||
LOST PROFITS, REVENUES, OR DATA, FINANCIAL LOSSES OR INDIRECT, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR
|
hope that you will enjoy using them. But there are certain things that we don't promise
|
||||||
PUNITIVE DAMAGES.
|
about our Services.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-4 uppercase font-bold">
|
<p className="mt-4 uppercase font-bold">
|
||||||
TO THE EXTENT PERMITTED BY LAW, THE TOTAL LIABILITY OF SURFSENSE, AND ITS SUPPLIERS AND DISTRIBUTORS, FOR ANY
|
OTHER THAN AS EXPRESSLY SET OUT IN THESE TERMS OR ADDITIONAL TERMS, NEITHER SURFSENSE
|
||||||
CLAIMS UNDER THESE TERMS, INCLUDING FOR ANY IMPLIED WARRANTIES, IS LIMITED TO THE AMOUNT YOU PAID US TO USE THE
|
NOR ITS SUPPLIERS OR DISTRIBUTORS MAKE ANY SPECIFIC PROMISES ABOUT THE SERVICES. FOR
|
||||||
SERVICES (OR, IF WE CHOOSE, TO SUPPLYING YOU THE SERVICES AGAIN).
|
EXAMPLE, WE DON'T MAKE ANY COMMITMENTS ABOUT THE CONTENT WITHIN THE SERVICES, THE
|
||||||
</p>
|
SPECIFIC FUNCTIONS OF THE SERVICES, OR THEIR RELIABILITY, AVAILABILITY, OR ABILITY TO
|
||||||
<p className="mt-4 uppercase font-bold">
|
MEET YOUR NEEDS. WE PROVIDE THE SERVICES "AS IS".
|
||||||
IN ALL CASES, SURFSENSE, AND ITS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE LIABLE FOR ANY LOSS OR DAMAGE THAT IS
|
</p>
|
||||||
NOT REASONABLY FORESEEABLE.
|
<p className="mt-4 uppercase font-bold">
|
||||||
</p>
|
SOME JURISDICTIONS PROVIDE FOR CERTAIN WARRANTIES, LIKE THE IMPLIED WARRANTY OF
|
||||||
</section>
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. TO THE EXTENT
|
||||||
|
PERMITTED BY LAW, WE EXCLUDE ALL WARRANTIES.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">9. Indemnification</h2>
|
<h2 className="text-2xl font-semibold mb-4">8. Liability for our Services</h2>
|
||||||
<p>
|
<p className="uppercase font-bold">
|
||||||
You agree to defend, indemnify, and hold harmless SurfSense, its affiliates, and their respective officers, directors,
|
WHEN PERMITTED BY LAW, SURFSENSE, AND SURFSENSE'S SUPPLIERS AND DISTRIBUTORS, WILL NOT
|
||||||
employees, and agents from and against any claims, liabilities, damages, judgments, awards, losses, costs, expenses, or
|
BE RESPONSIBLE FOR LOST PROFITS, REVENUES, OR DATA, FINANCIAL LOSSES OR INDIRECT,
|
||||||
fees (including reasonable attorneys' fees) arising out of or relating to your violation of these Terms or your use of
|
SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES.
|
||||||
the Services, including, but not limited to, any use of the Services' content, services, and products other than as
|
</p>
|
||||||
expressly authorized in these Terms.
|
<p className="mt-4 uppercase font-bold">
|
||||||
</p>
|
TO THE EXTENT PERMITTED BY LAW, THE TOTAL LIABILITY OF SURFSENSE, AND ITS SUPPLIERS AND
|
||||||
</section>
|
DISTRIBUTORS, FOR ANY CLAIMS UNDER THESE TERMS, INCLUDING FOR ANY IMPLIED WARRANTIES, IS
|
||||||
|
LIMITED TO THE AMOUNT YOU PAID US TO USE THE SERVICES (OR, IF WE CHOOSE, TO SUPPLYING
|
||||||
|
YOU THE SERVICES AGAIN).
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 uppercase font-bold">
|
||||||
|
IN ALL CASES, SURFSENSE, AND ITS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE LIABLE FOR ANY
|
||||||
|
LOSS OR DAMAGE THAT IS NOT REASONABLY FORESEEABLE.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">10. Dispute Resolution</h2>
|
<h2 className="text-2xl font-semibold mb-4">9. Indemnification</h2>
|
||||||
<p>
|
<p>
|
||||||
Any dispute arising out of or relating to these Terms, including the validity, interpretation, breach, or termination
|
You agree to defend, indemnify, and hold harmless SurfSense, its affiliates, and their
|
||||||
thereof, shall be resolved by arbitration in accordance with the rules of the arbitration authority in the jurisdiction
|
respective officers, directors, employees, and agents from and against any claims,
|
||||||
where SurfSense operates. The arbitration shall be conducted by one arbitrator, in the English language, and the
|
liabilities, damages, judgments, awards, losses, costs, expenses, or fees (including
|
||||||
decision of the arbitrator shall be final and binding on the parties.
|
reasonable attorneys' fees) arising out of or relating to your violation of these Terms
|
||||||
</p>
|
or your use of the Services, including, but not limited to, any use of the Services'
|
||||||
<p className="mt-4">
|
content, services, and products other than as expressly authorized in these Terms.
|
||||||
You agree that any dispute resolution proceedings will be conducted only on an individual basis and not in a class,
|
</p>
|
||||||
consolidated, or representative action. If for any reason a claim proceeds in court rather than in arbitration, you
|
</section>
|
||||||
waive any right to a jury trial.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">11. About these Terms</h2>
|
<h2 className="text-2xl font-semibold mb-4">10. Dispute Resolution</h2>
|
||||||
<p>
|
<p>
|
||||||
We may modify these terms or any additional terms that apply to a Service to, for example, reflect changes to
|
Any dispute arising out of or relating to these Terms, including the validity,
|
||||||
the law or changes to our Services. You should look at the terms regularly. If you do not agree to the modified
|
interpretation, breach, or termination thereof, shall be resolved by arbitration in
|
||||||
terms for a Service, you should discontinue your use of that Service.
|
accordance with the rules of the arbitration authority in the jurisdiction where
|
||||||
</p>
|
SurfSense operates. The arbitration shall be conducted by one arbitrator, in the English
|
||||||
<p className="mt-4">
|
language, and the decision of the arbitrator shall be final and binding on the parties.
|
||||||
If there is a conflict between these terms and the additional terms, the additional terms will control for that conflict.
|
</p>
|
||||||
These terms control the relationship between SurfSense and you. They do not create any third-party beneficiary rights.
|
<p className="mt-4">
|
||||||
</p>
|
You agree that any dispute resolution proceedings will be conducted only on an
|
||||||
<p className="mt-4">
|
individual basis and not in a class, consolidated, or representative action. If for any
|
||||||
If you do not comply with these terms, and we don't take action right away, this doesn't mean that we are giving up
|
reason a claim proceeds in court rather than in arbitration, you waive any right to a
|
||||||
any rights that we may have (such as taking action in the future). If it turns out that a particular term is not
|
jury trial.
|
||||||
enforceable, this will not affect any other terms.
|
</p>
|
||||||
</p>
|
</section>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
<h2 className="text-2xl font-semibold mb-4">12. Contact Us</h2>
|
<h2 className="text-2xl font-semibold mb-4">11. About these Terms</h2>
|
||||||
<p>
|
<p>
|
||||||
If you have any questions about these Terms, please contact us at:
|
We may modify these terms or any additional terms that apply to a Service to, for
|
||||||
</p>
|
example, reflect changes to the law or changes to our Services. You should look at the
|
||||||
<p className="mt-2">
|
terms regularly. If you do not agree to the modified terms for a Service, you should
|
||||||
<strong>Email:</strong> vermarohanfinal@gmail.com
|
discontinue your use of that Service.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
<p className="mt-4">
|
||||||
</div>
|
If there is a conflict between these terms and the additional terms, the additional
|
||||||
</div>
|
terms will control for that conflict. These terms control the relationship between
|
||||||
);
|
SurfSense and you. They do not create any third-party beneficiary rights.
|
||||||
}
|
</p>
|
||||||
|
<p className="mt-4">
|
||||||
|
If you do not comply with these terms, and we don't take action right away, this doesn't
|
||||||
|
mean that we are giving up any rights that we may have (such as taking action in the
|
||||||
|
future). If it turns out that a particular term is not enforceable, this will not affect
|
||||||
|
any other terms.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<h2 className="text-2xl font-semibold mb-4">12. Contact Us</h2>
|
||||||
|
<p>If you have any questions about these Terms, please contact us at:</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
<strong>Email:</strong> vermarohanfinal@gmail.com
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
115
surfsense_web/biome.json
Normal file
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",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "new-york",
|
"style": "new-york",
|
||||||
"rsc": true,
|
"rsc": true,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "",
|
"config": "",
|
||||||
"css": "app/globals.css",
|
"css": "app/globals.css",
|
||||||
"baseColor": "neutral",
|
"baseColor": "neutral",
|
||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
"ui": "@/components/ui",
|
"ui": "@/components/ui",
|
||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide"
|
"iconLibrary": "lucide"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,106 +1,97 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
import {
|
||||||
IconBrandDiscord,
|
IconBrandDiscord,
|
||||||
IconBrandGithub,
|
IconBrandGithub,
|
||||||
IconBrandLinkedin,
|
IconBrandLinkedin,
|
||||||
IconBrandTwitter,
|
IconBrandTwitter,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React from "react";
|
import type React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const pages = [
|
const pages = [
|
||||||
{
|
{
|
||||||
title: "Privacy",
|
title: "Privacy",
|
||||||
href: "/privacy",
|
href: "/privacy",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Terms",
|
title: "Terms",
|
||||||
href: "/terms",
|
href: "/terms",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-neutral-100 dark:border-white/[0.1] px-8 py-20 bg-white dark:bg-neutral-950 w-full relative overflow-hidden">
|
<div className="border-t border-neutral-100 dark:border-white/[0.1] px-8 py-20 bg-white dark:bg-neutral-950 w-full relative overflow-hidden">
|
||||||
<div className="max-w-7xl mx-auto text-sm text-neutral-500 justify-between items-start md:px-8">
|
<div className="max-w-7xl mx-auto text-sm text-neutral-500 justify-between items-start md:px-8">
|
||||||
<div className="flex flex-col items-center justify-center w-full relative">
|
<div className="flex flex-col items-center justify-center w-full relative">
|
||||||
<div className="mr-0 md:mr-4 md:flex mb-4">
|
<div className="mr-0 md:mr-4 md:flex mb-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="font-medium text-black dark:text-white ml-2">SurfSense</span>
|
<span className="font-medium text-black dark:text-white ml-2">SurfSense</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="transition-colors flex sm:flex-row flex-col hover:text-text-neutral-800 text-neutral-600 dark:text-neutral-300 list-none gap-4">
|
<ul className="transition-colors flex sm:flex-row flex-col hover:text-text-neutral-800 text-neutral-600 dark:text-neutral-300 list-none gap-4">
|
||||||
{pages.map((page, idx) => (
|
{pages.map((page) => (
|
||||||
<li key={"pages" + idx} className="list-none">
|
<li key={`pages-${page.title}`} className="list-none">
|
||||||
<Link
|
<Link className="transition-colors hover:text-text-neutral-800" href={page.href}>
|
||||||
className="transition-colors hover:text-text-neutral-800"
|
{page.title}
|
||||||
href={page.href}
|
</Link>
|
||||||
>
|
</li>
|
||||||
{page.title}
|
))}
|
||||||
</Link>
|
</ul>
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<GridLineHorizontal className="max-w-7xl mx-auto mt-8" />
|
<GridLineHorizontal className="max-w-7xl mx-auto mt-8" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex sm:flex-row flex-col justify-between mt-8 items-center w-full">
|
<div className="flex sm:flex-row flex-col justify-between mt-8 items-center w-full">
|
||||||
<p className="text-neutral-500 dark:text-neutral-400 mb-8 sm:mb-0">
|
<p className="text-neutral-500 dark:text-neutral-400 mb-8 sm:mb-0">
|
||||||
© SurfSense 2025
|
© SurfSense 2025
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Link href="https://x.com/mod_setter">
|
<Link href="https://x.com/mod_setter">
|
||||||
<IconBrandTwitter className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
<IconBrandTwitter className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="https://www.linkedin.com/in/rohan-verma-sde/">
|
<Link href="https://www.linkedin.com/in/rohan-verma-sde/">
|
||||||
<IconBrandLinkedin className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
<IconBrandLinkedin className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="https://github.com/MODSetter">
|
<Link href="https://github.com/MODSetter">
|
||||||
<IconBrandGithub className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
<IconBrandGithub className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="https://discord.gg/ejRNvftDp9">
|
<Link href="https://discord.gg/ejRNvftDp9">
|
||||||
<IconBrandDiscord className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
<IconBrandDiscord className="h-6 w-6 text-neutral-500 dark:text-neutral-300" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const GridLineHorizontal = ({
|
const GridLineHorizontal = ({ className, offset }: { className?: string; offset?: string }) => {
|
||||||
className,
|
return (
|
||||||
offset,
|
<div
|
||||||
}: {
|
style={
|
||||||
className?: string;
|
{
|
||||||
offset?: string;
|
"--background": "#ffffff",
|
||||||
}) => {
|
"--color": "rgba(0, 0, 0, 0.2)",
|
||||||
return (
|
"--height": "1px",
|
||||||
<div
|
"--width": "5px",
|
||||||
style={
|
"--fade-stop": "90%",
|
||||||
{
|
"--offset": offset || "200px", //-100px if you want to keep the line inside
|
||||||
"--background": "#ffffff",
|
"--color-dark": "rgba(255, 255, 255, 0.2)",
|
||||||
"--color": "rgba(0, 0, 0, 0.2)",
|
maskComposite: "exclude",
|
||||||
"--height": "1px",
|
} as React.CSSProperties
|
||||||
"--width": "5px",
|
}
|
||||||
"--fade-stop": "90%",
|
className={cn(
|
||||||
"--offset": offset || "200px", //-100px if you want to keep the line inside
|
"w-[calc(100%+var(--offset))] h-[var(--height)]",
|
||||||
"--color-dark": "rgba(255, 255, 255, 0.2)",
|
"bg-[linear-gradient(to_right,var(--color),var(--color)_50%,transparent_0,transparent)]",
|
||||||
maskComposite: "exclude",
|
"[background-size:var(--width)_var(--height)]",
|
||||||
} as React.CSSProperties
|
"[mask:linear-gradient(to_left,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_right,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
|
||||||
}
|
"[mask-composite:exclude]",
|
||||||
className={cn(
|
"z-30",
|
||||||
"w-[calc(100%+var(--offset))] h-[var(--height)]",
|
"dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
|
||||||
"bg-[linear-gradient(to_right,var(--color),var(--color)_50%,transparent_0,transparent)]",
|
className
|
||||||
"[background-size:var(--width)_var(--height)]",
|
)}
|
||||||
"[mask:linear-gradient(to_left,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_right,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
|
></div>
|
||||||
"[mask-composite:exclude]",
|
);
|
||||||
"z-30",
|
};
|
||||||
"dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
></div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
import Link from "next/link";
|
|
||||||
import React from "react";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export const Logo = ({ className }: { className?: string }) => {
|
export const Logo = ({ className }: { className?: string }) => {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link href="/">
|
||||||
href="/"
|
<Image src="/icon-128.png" className={cn(className)} alt="logo" width={128} height={128} />
|
||||||
>
|
</Link>
|
||||||
<Image
|
);
|
||||||
src="/icon-128.png"
|
|
||||||
className={cn(className)}
|
|
||||||
alt="logo"
|
|
||||||
width={128}
|
|
||||||
height={128}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,290 +1,286 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { cn } from "@/lib/utils";
|
import { IconMenu2, IconUser, IconX } from "@tabler/icons-react";
|
||||||
import { IconMenu2, IconX, IconBrandGoogleFilled, IconUser } from "@tabler/icons-react";
|
import { AnimatePresence, motion, useMotionValueEvent, useScroll } from "framer-motion";
|
||||||
import {
|
|
||||||
motion,
|
|
||||||
AnimatePresence,
|
|
||||||
useScroll,
|
|
||||||
useMotionValueEvent,
|
|
||||||
} from "framer-motion";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React, { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Button } from "./ui/button";
|
import { cn } from "@/lib/utils";
|
||||||
import { Logo } from "./Logo";
|
import { Logo } from "./Logo";
|
||||||
import { ThemeTogglerComponent } from "./theme/theme-toggle";
|
import { ThemeTogglerComponent } from "./theme/theme-toggle";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
navItems: {
|
navItems: {
|
||||||
name: string;
|
name: string;
|
||||||
link: string;
|
link: string;
|
||||||
}[];
|
}[];
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Navbar = () => {
|
export const Navbar = () => {
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
name: "Docs",
|
name: "Docs",
|
||||||
link: "/docs",
|
link: "/docs",
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// name: "Product",
|
// name: "Product",
|
||||||
// link: "/#product",
|
// link: "/#product",
|
||||||
// },
|
// },
|
||||||
// {
|
// {
|
||||||
// name: "Pricing",
|
// name: "Pricing",
|
||||||
// link: "/#pricing",
|
// link: "/#pricing",
|
||||||
// },
|
// },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const { scrollY } = useScroll({
|
const { scrollY } = useScroll({
|
||||||
target: ref,
|
target: ref,
|
||||||
offset: ["start start", "end start"],
|
offset: ["start start", "end start"],
|
||||||
});
|
});
|
||||||
const [visible, setVisible] = useState<boolean>(false);
|
const [visible, setVisible] = useState<boolean>(false);
|
||||||
|
|
||||||
useMotionValueEvent(scrollY, "change", (latest) => {
|
useMotionValueEvent(scrollY, "change", (latest) => {
|
||||||
if (latest > 100) {
|
if (latest > 100) {
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
} else {
|
} else {
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div ref={ref} className="w-full fixed top-2 inset-x-0 z-50">
|
<motion.div ref={ref} className="w-full fixed top-2 inset-x-0 z-50">
|
||||||
<DesktopNav visible={visible} navItems={navItems} />
|
<DesktopNav visible={visible} navItems={navItems} />
|
||||||
<MobileNav visible={visible} navItems={navItems} />
|
<MobileNav visible={visible} navItems={navItems} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DesktopNav = ({ navItems, visible }: NavbarProps) => {
|
const DesktopNav = ({ navItems, visible }: NavbarProps) => {
|
||||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const handleGoogleLogin = () => {
|
const handleGoogleLogin = () => {
|
||||||
// Redirect to the login page
|
// Redirect to the login page
|
||||||
window.location.href = '/login';
|
window.location.href = "/login";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
onMouseLeave={() => setHoveredIndex(null)}
|
onMouseLeave={() => setHoveredIndex(null)}
|
||||||
animate={{
|
animate={{
|
||||||
backdropFilter: "blur(16px)",
|
backdropFilter: "blur(16px)",
|
||||||
background: visible
|
background: visible
|
||||||
? "rgba(var(--background-rgb), 0.8)"
|
? "rgba(var(--background-rgb), 0.8)"
|
||||||
: "rgba(var(--background-rgb), 0.6)",
|
: "rgba(var(--background-rgb), 0.6)",
|
||||||
width: visible ? "38%" : "80%",
|
width: visible ? "38%" : "80%",
|
||||||
height: visible ? "48px" : "64px",
|
height: visible ? "48px" : "64px",
|
||||||
y: visible ? 8 : 0,
|
y: visible ? 8 : 0,
|
||||||
}}
|
}}
|
||||||
initial={{
|
initial={{
|
||||||
width: "80%",
|
width: "80%",
|
||||||
height: "64px",
|
height: "64px",
|
||||||
background: "rgba(var(--background-rgb), 0.6)",
|
background: "rgba(var(--background-rgb), 0.6)",
|
||||||
}}
|
}}
|
||||||
transition={{
|
transition={{
|
||||||
type: "spring",
|
type: "spring",
|
||||||
stiffness: 400,
|
stiffness: 400,
|
||||||
damping: 30,
|
damping: 30,
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"hidden lg:flex flex-row self-center items-center justify-between py-2 mx-auto px-6 rounded-full relative z-[60] backdrop-saturate-[1.8]",
|
"hidden lg:flex flex-row self-center items-center justify-between py-2 mx-auto px-6 rounded-full relative z-[60] backdrop-saturate-[1.8]",
|
||||||
visible ? "border dark:border-white/10 border-gray-300/30" : "border-0"
|
visible ? "border dark:border-white/10 border-gray-300/30" : "border-0"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={
|
||||||
"--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'",
|
{
|
||||||
} as React.CSSProperties}
|
"--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'",
|
||||||
>
|
} as React.CSSProperties
|
||||||
<div className="flex flex-row items-center gap-2">
|
}
|
||||||
<Logo className="h-8 w-8 rounded-md" />
|
>
|
||||||
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
|
<div className="flex flex-row items-center gap-2">
|
||||||
</div>
|
<Logo className="h-8 w-8 rounded-md" />
|
||||||
<div className="flex items-center gap-4">
|
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
|
||||||
<motion.div
|
</div>
|
||||||
className="lg:flex flex-row items-center justify-end space-x-1 text-sm"
|
<div className="flex items-center gap-4">
|
||||||
animate={{
|
<motion.div
|
||||||
scale: visible ? 0.9 : 1,
|
className="lg:flex flex-row items-center justify-end space-x-1 text-sm"
|
||||||
}}
|
animate={{
|
||||||
>
|
scale: visible ? 0.9 : 1,
|
||||||
{navItems.map((navItem, idx) => (
|
}}
|
||||||
<motion.div
|
>
|
||||||
key={`nav-item-${idx}`}
|
{navItems.map((navItem, idx) => (
|
||||||
onHoverStart={() => setHoveredIndex(idx)}
|
<motion.div
|
||||||
className="relative"
|
key={`nav-item-${navItem.name}`}
|
||||||
>
|
onHoverStart={() => setHoveredIndex(idx)}
|
||||||
<Link
|
className="relative"
|
||||||
className="dark:text-white/90 text-gray-800 relative px-3 py-1.5 transition-colors"
|
>
|
||||||
href={navItem.link}
|
<Link
|
||||||
>
|
className="dark:text-white/90 text-gray-800 relative px-3 py-1.5 transition-colors"
|
||||||
<span className="relative z-10">{navItem.name}</span>
|
href={navItem.link}
|
||||||
{hoveredIndex === idx && (
|
>
|
||||||
<motion.div
|
<span className="relative z-10">{navItem.name}</span>
|
||||||
layoutId="menu-hover"
|
{hoveredIndex === idx && (
|
||||||
className="absolute inset-0 rounded-full dark:bg-gradient-to-r dark:from-white/10 dark:to-white/20 bg-gradient-to-r from-gray-200 to-gray-300"
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
layoutId="menu-hover"
|
||||||
animate={{
|
className="absolute inset-0 rounded-full dark:bg-gradient-to-r dark:from-white/10 dark:to-white/20 bg-gradient-to-r from-gray-200 to-gray-300"
|
||||||
opacity: 1,
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
scale: 1.1,
|
animate={{
|
||||||
background: "var(--tw-dark) ? radial-gradient(circle at center, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0.1) 50%, transparent 100%) : radial-gradient(circle at center, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.03) 50%, transparent 100%)",
|
opacity: 1,
|
||||||
}}
|
scale: 1.1,
|
||||||
exit={{
|
background:
|
||||||
opacity: 0,
|
"var(--tw-dark) ? radial-gradient(circle at center, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0.1) 50%, transparent 100%) : radial-gradient(circle at center, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.03) 50%, transparent 100%)",
|
||||||
scale: 0.8,
|
}}
|
||||||
transition: {
|
exit={{
|
||||||
duration: 0.2,
|
opacity: 0,
|
||||||
},
|
scale: 0.8,
|
||||||
}}
|
transition: {
|
||||||
transition={{
|
duration: 0.2,
|
||||||
type: "spring",
|
},
|
||||||
bounce: 0.4,
|
}}
|
||||||
duration: 0.4,
|
transition={{
|
||||||
}}
|
type: "spring",
|
||||||
/>
|
bounce: 0.4,
|
||||||
)}
|
duration: 0.4,
|
||||||
</Link>
|
}}
|
||||||
</motion.div>
|
/>
|
||||||
))}
|
)}
|
||||||
</motion.div>
|
</Link>
|
||||||
<ThemeTogglerComponent />
|
</motion.div>
|
||||||
<AnimatePresence mode="popLayout" initial={false}>
|
))}
|
||||||
{!visible && (
|
</motion.div>
|
||||||
<motion.div
|
<ThemeTogglerComponent />
|
||||||
initial={{ scale: 0.8, opacity: 0 }}
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
animate={{
|
{!visible && (
|
||||||
scale: 1,
|
<motion.div
|
||||||
opacity: 1,
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
transition: {
|
animate={{
|
||||||
type: "spring",
|
scale: 1,
|
||||||
stiffness: 400,
|
opacity: 1,
|
||||||
damping: 25,
|
transition: {
|
||||||
},
|
type: "spring",
|
||||||
}}
|
stiffness: 400,
|
||||||
exit={{
|
damping: 25,
|
||||||
scale: 0.8,
|
},
|
||||||
opacity: 0,
|
}}
|
||||||
transition: {
|
exit={{
|
||||||
duration: 0.2,
|
scale: 0.8,
|
||||||
},
|
opacity: 0,
|
||||||
}}
|
transition: {
|
||||||
>
|
duration: 0.2,
|
||||||
<Button
|
},
|
||||||
onClick={handleGoogleLogin}
|
}}
|
||||||
variant="outline"
|
>
|
||||||
className="hidden cursor-pointer md:flex items-center gap-2 rounded-full dark:bg-white/20 dark:hover:bg-white/30 dark:text-white bg-gray-100 hover:bg-gray-200 text-gray-800 border-0"
|
<Button
|
||||||
>
|
onClick={handleGoogleLogin}
|
||||||
<IconUser className="h-4 w-4" />
|
variant="outline"
|
||||||
<span>Sign in</span>
|
className="hidden cursor-pointer md:flex items-center gap-2 rounded-full dark:bg-white/20 dark:hover:bg-white/30 dark:text-white bg-gray-100 hover:bg-gray-200 text-gray-800 border-0"
|
||||||
</Button>
|
>
|
||||||
</motion.div>
|
<IconUser className="h-4 w-4" />
|
||||||
)}
|
<span>Sign in</span>
|
||||||
</AnimatePresence>
|
</Button>
|
||||||
</div>
|
</motion.div>
|
||||||
</motion.div>
|
)}
|
||||||
);
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const MobileNav = ({ navItems, visible }: NavbarProps) => {
|
const MobileNav = ({ navItems, visible }: NavbarProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const handleGoogleLogin = () => {
|
const handleGoogleLogin = () => {
|
||||||
// Redirect to the login page
|
// Redirect to the login page
|
||||||
window.location.href = "./login";
|
window.location.href = "./login";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<motion.div
|
||||||
<motion.div
|
animate={{
|
||||||
animate={{
|
backdropFilter: "blur(16px)",
|
||||||
backdropFilter: "blur(16px)",
|
background: visible
|
||||||
background: visible
|
? "rgba(var(--background-rgb), 0.8)"
|
||||||
? "rgba(var(--background-rgb), 0.8)"
|
: "rgba(var(--background-rgb), 0.6)",
|
||||||
: "rgba(var(--background-rgb), 0.6)",
|
width: visible ? "80%" : "90%",
|
||||||
width: visible ? "80%" : "90%",
|
y: visible ? 0 : 8,
|
||||||
y: visible ? 0 : 8,
|
borderRadius: open ? "24px" : "full",
|
||||||
borderRadius: open ? "24px" : "full",
|
padding: "8px 16px",
|
||||||
padding: "8px 16px",
|
}}
|
||||||
}}
|
initial={{
|
||||||
initial={{
|
width: "80%",
|
||||||
width: "80%",
|
background: "rgba(var(--background-rgb), 0.6)",
|
||||||
background: "rgba(var(--background-rgb), 0.6)",
|
}}
|
||||||
}}
|
transition={{
|
||||||
transition={{
|
type: "spring",
|
||||||
type: "spring",
|
stiffness: 400,
|
||||||
stiffness: 400,
|
damping: 30,
|
||||||
damping: 30,
|
}}
|
||||||
}}
|
className={cn(
|
||||||
className={cn(
|
"flex relative flex-col lg:hidden w-full justify-between items-center max-w-[calc(100vw-2rem)] mx-auto z-50 backdrop-saturate-[1.8] rounded-full",
|
||||||
"flex relative flex-col lg:hidden w-full justify-between items-center max-w-[calc(100vw-2rem)] mx-auto z-50 backdrop-saturate-[1.8] rounded-full",
|
visible ? "border border-solid dark:border-white/40 border-gray-300/30" : "border-0"
|
||||||
visible ? "border border-solid dark:border-white/40 border-gray-300/30" : "border-0"
|
)}
|
||||||
)}
|
style={
|
||||||
style={{
|
{
|
||||||
"--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'",
|
"--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'",
|
||||||
} as React.CSSProperties}
|
} as React.CSSProperties
|
||||||
>
|
}
|
||||||
<div className="flex flex-row justify-between items-center w-full">
|
>
|
||||||
<Logo className="h-8 w-8 rounded-md" />
|
<div className="flex flex-row justify-between items-center w-full">
|
||||||
<div className="flex items-center gap-2">
|
<Logo className="h-8 w-8 rounded-md" />
|
||||||
<ThemeTogglerComponent />
|
<div className="flex items-center gap-2">
|
||||||
{open ? (
|
<ThemeTogglerComponent />
|
||||||
<IconX className="dark:text-white/90 text-gray-800" onClick={() => setOpen(!open)} />
|
{open ? (
|
||||||
) : (
|
<IconX className="dark:text-white/90 text-gray-800" onClick={() => setOpen(!open)} />
|
||||||
<IconMenu2
|
) : (
|
||||||
className="dark:text-white/90 text-gray-800"
|
<IconMenu2
|
||||||
onClick={() => setOpen(!open)}
|
className="dark:text-white/90 text-gray-800"
|
||||||
/>
|
onClick={() => setOpen(!open)}
|
||||||
)}
|
/>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{
|
initial={{
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
y: -20,
|
y: -20,
|
||||||
}}
|
}}
|
||||||
animate={{
|
animate={{
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
}}
|
}}
|
||||||
exit={{
|
exit={{
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
y: -20,
|
y: -20,
|
||||||
}}
|
}}
|
||||||
transition={{
|
transition={{
|
||||||
type: "spring",
|
type: "spring",
|
||||||
stiffness: 400,
|
stiffness: 400,
|
||||||
damping: 30,
|
damping: 30,
|
||||||
}}
|
}}
|
||||||
className="flex rounded-3xl absolute top-16 dark:bg-black/80 bg-white/90 backdrop-blur-xl backdrop-saturate-[1.8] inset-x-0 z-50 flex-col items-start justify-start gap-4 w-full px-6 py-8"
|
className="flex rounded-3xl absolute top-16 dark:bg-black/80 bg-white/90 backdrop-blur-xl backdrop-saturate-[1.8] inset-x-0 z-50 flex-col items-start justify-start gap-4 w-full px-6 py-8"
|
||||||
>
|
>
|
||||||
{navItems.map(
|
{navItems.map((navItem: { link: string; name: string }) => (
|
||||||
(navItem: { link: string; name: string }, idx: number) => (
|
<Link
|
||||||
<Link
|
key={`link-${navItem.name}`}
|
||||||
key={`link=${idx}`}
|
href={navItem.link}
|
||||||
href={navItem.link}
|
onClick={() => setOpen(false)}
|
||||||
onClick={() => setOpen(false)}
|
className="relative dark:text-white/90 text-gray-800 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||||
className="relative dark:text-white/90 text-gray-800 hover:text-gray-900 dark:hover:text-white transition-colors"
|
>
|
||||||
>
|
<motion.span className="block">{navItem.name}</motion.span>
|
||||||
<motion.span className="block">{navItem.name}</motion.span>
|
</Link>
|
||||||
</Link>
|
))}
|
||||||
)
|
<Button
|
||||||
)}
|
onClick={handleGoogleLogin}
|
||||||
<Button
|
variant="outline"
|
||||||
onClick={handleGoogleLogin}
|
className="flex cursor-pointer items-center gap-2 mt-4 w-full justify-center rounded-full dark:bg-white/20 dark:hover:bg-white/30 dark:text-white bg-gray-100 hover:bg-gray-200 text-gray-800 border-0"
|
||||||
variant="outline"
|
>
|
||||||
className="flex cursor-pointer items-center gap-2 mt-4 w-full justify-center rounded-full dark:bg-white/20 dark:hover:bg-white/30 dark:text-white bg-gray-100 hover:bg-gray-200 text-gray-800 border-0"
|
<IconUser className="h-4 w-4" />
|
||||||
>
|
<span>Sign in</span>
|
||||||
<IconUser className="h-4 w-4" />
|
</Button>
|
||||||
<span>Sign in</span>
|
</motion.div>
|
||||||
</Button>
|
)}
|
||||||
</motion.div>
|
</AnimatePresence>
|
||||||
)}
|
</motion.div>
|
||||||
</AnimatePresence>
|
);
|
||||||
</motion.div>
|
};
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,55 @@
|
||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useEffect } from "react";
|
||||||
|
|
||||||
interface TokenHandlerProps {
|
interface TokenHandlerProps {
|
||||||
redirectPath?: string; // Path to redirect after storing token
|
redirectPath?: string; // Path to redirect after storing token
|
||||||
tokenParamName?: string; // Name of the URL parameter containing the token
|
tokenParamName?: string; // Name of the URL parameter containing the token
|
||||||
storageKey?: string; // Key to use when storing in localStorage
|
storageKey?: string; // Key to use when storing in localStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Client component that extracts a token from URL parameters and stores it in localStorage
|
* Client component that extracts a token from URL parameters and stores it in localStorage
|
||||||
*
|
*
|
||||||
* @param redirectPath - Path to redirect after storing token (default: '/')
|
* @param redirectPath - Path to redirect after storing token (default: '/')
|
||||||
* @param tokenParamName - Name of the URL parameter containing the token (default: 'token')
|
* @param tokenParamName - Name of the URL parameter containing the token (default: 'token')
|
||||||
* @param storageKey - Key to use when storing in localStorage (default: 'auth_token')
|
* @param storageKey - Key to use when storing in localStorage (default: 'auth_token')
|
||||||
*/
|
*/
|
||||||
const TokenHandler = ({
|
const TokenHandler = ({
|
||||||
redirectPath = '/',
|
redirectPath = "/",
|
||||||
tokenParamName = 'token',
|
tokenParamName = "token",
|
||||||
storageKey = 'surfsense_bearer_token'
|
storageKey = "surfsense_bearer_token",
|
||||||
}: TokenHandlerProps) => {
|
}: TokenHandlerProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only run on client-side
|
// Only run on client-side
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
// Get token from URL parameters
|
// Get token from URL parameters
|
||||||
const token = searchParams.get(tokenParamName);
|
const token = searchParams.get(tokenParamName);
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
// Store token in localStorage
|
// Store token in localStorage
|
||||||
localStorage.setItem(storageKey, token);
|
localStorage.setItem(storageKey, token);
|
||||||
// console.log(`Token stored in localStorage with key: ${storageKey}`);
|
// console.log(`Token stored in localStorage with key: ${storageKey}`);
|
||||||
|
|
||||||
// Redirect to specified path
|
|
||||||
router.push(redirectPath);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error storing token in localStorage:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [searchParams, tokenParamName, storageKey, redirectPath, router]);
|
|
||||||
|
|
||||||
return (
|
// Redirect to specified path
|
||||||
<div className="flex items-center justify-center min-h-[200px]">
|
router.push(redirectPath);
|
||||||
<p className="text-gray-500">Processing authentication...</p>
|
} catch (error) {
|
||||||
</div>
|
console.error("Error storing token in localStorage:", error);
|
||||||
);
|
}
|
||||||
|
}
|
||||||
|
}, [searchParams, tokenParamName, storageKey, redirectPath, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[200px]">
|
||||||
|
<p className="text-gray-500">Processing authentication...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TokenHandler;
|
export default TokenHandler;
|
||||||
|
|
|
||||||
|
|
@ -1,101 +1,80 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
|
import { BadgeCheck, LogOut, Settings } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
BadgeCheck,
|
DropdownMenu,
|
||||||
ChevronsUpDown,
|
DropdownMenuContent,
|
||||||
LogOut,
|
DropdownMenuGroup,
|
||||||
Settings,
|
DropdownMenuItem,
|
||||||
} from "lucide-react"
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
import {
|
DropdownMenuTrigger,
|
||||||
Avatar,
|
} from "@/components/ui/dropdown-menu";
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
} from "@/components/ui/avatar"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { useRouter, useParams } from "next/navigation"
|
|
||||||
|
|
||||||
export function UserDropdown({
|
export function UserDropdown({
|
||||||
user,
|
user,
|
||||||
}: {
|
}: {
|
||||||
user: {
|
user: {
|
||||||
name: string
|
name: string;
|
||||||
email: string
|
email: string;
|
||||||
avatar: string
|
avatar: string;
|
||||||
}
|
};
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
try {
|
try {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem('surfsense_bearer_token');
|
localStorage.removeItem("surfsense_bearer_token");
|
||||||
router.push('/');
|
router.push("/");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during logout:', error);
|
console.error("Error during logout:", error);
|
||||||
// Optionally, provide user feedback
|
// Optionally, provide user feedback
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
alert('Logout failed. Please try again.');
|
alert("Logout failed. Please try again.");
|
||||||
router.push('/');
|
router.push("/");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||||
variant="ghost"
|
<Avatar className="h-8 w-8">
|
||||||
className="relative h-10 w-10 rounded-full"
|
<AvatarImage src={user.avatar} alt={user.name} />
|
||||||
>
|
<AvatarFallback>{user.name.charAt(0)?.toUpperCase() || "?"}</AvatarFallback>
|
||||||
<Avatar className="h-8 w-8">
|
</Avatar>
|
||||||
<AvatarImage src={user.avatar} alt={user.name} />
|
</Button>
|
||||||
<AvatarFallback>{user.name.charAt(0)?.toUpperCase() || '?'}</AvatarFallback>
|
</DropdownMenuTrigger>
|
||||||
</Avatar>
|
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||||
</Button>
|
<DropdownMenuLabel className="font-normal">
|
||||||
</DropdownMenuTrigger>
|
<div className="flex flex-col space-y-1">
|
||||||
<DropdownMenuContent
|
<p className="text-sm font-medium leading-none">{user.name}</p>
|
||||||
className="w-56"
|
<p className="text-xs leading-none text-muted-foreground">{user.email}</p>
|
||||||
align="end"
|
</div>
|
||||||
forceMount
|
</DropdownMenuLabel>
|
||||||
>
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel className="font-normal">
|
<DropdownMenuGroup>
|
||||||
<div className="flex flex-col space-y-1">
|
<DropdownMenuItem onClick={() => router.push(`/dashboard/api-key`)}>
|
||||||
<p className="text-sm font-medium leading-none">{user.name}</p>
|
<BadgeCheck className="mr-2 h-4 w-4" />
|
||||||
<p className="text-xs leading-none text-muted-foreground">
|
API Key
|
||||||
{user.email}
|
</DropdownMenuItem>
|
||||||
</p>
|
</DropdownMenuGroup>
|
||||||
</div>
|
<DropdownMenuSeparator />
|
||||||
</DropdownMenuLabel>
|
<DropdownMenuItem onClick={() => router.push(`/settings`)}>
|
||||||
<DropdownMenuSeparator />
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
<DropdownMenuGroup>
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => router.push(`/dashboard/api-key`)}>
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
<BadgeCheck className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
API Key
|
Log out
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenu>
|
||||||
<DropdownMenuSeparator />
|
);
|
||||||
<DropdownMenuItem onClick={() => router.push(`/settings`)}>
|
}
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
|
||||||
Settings
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
|
||||||
Log out
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,160 +1,152 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Manrope } from "next/font/google";
|
|
||||||
import React, {
|
|
||||||
useRef,
|
|
||||||
useEffect,
|
|
||||||
useReducer,
|
|
||||||
useMemo
|
|
||||||
} from "react";
|
|
||||||
import { RoughNotation, RoughNotationGroup } from "react-rough-notation";
|
|
||||||
import { useInView } from "framer-motion";
|
import { useInView } from "framer-motion";
|
||||||
|
import { Manrope } from "next/font/google";
|
||||||
|
import { useEffect, useMemo, useReducer, useRef } from "react";
|
||||||
|
import { RoughNotation, RoughNotationGroup } from "react-rough-notation";
|
||||||
import { useSidebar } from "@/components/ui/sidebar";
|
import { useSidebar } from "@/components/ui/sidebar";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// Font configuration - could be moved to a global font config file
|
// Font configuration - could be moved to a global font config file
|
||||||
const manrope = Manrope({
|
const manrope = Manrope({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
weight: ["400", "700"],
|
weight: ["400", "700"],
|
||||||
display: "swap", // Optimize font loading
|
display: "swap", // Optimize font loading
|
||||||
variable: "--font-manrope"
|
variable: "--font-manrope",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Constants for timing - makes it easier to adjust and more maintainable
|
// Constants for timing - makes it easier to adjust and more maintainable
|
||||||
const TIMING = {
|
const TIMING = {
|
||||||
SIDEBAR_TRANSITION: 300, // Wait for sidebar transition + buffer
|
SIDEBAR_TRANSITION: 300, // Wait for sidebar transition + buffer
|
||||||
LAYOUT_SETTLE: 100, // Small delay to ensure layout is fully settled
|
LAYOUT_SETTLE: 100, // Small delay to ensure layout is fully settled
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Animation configuration
|
// Animation configuration
|
||||||
const ANIMATION_CONFIG = {
|
const ANIMATION_CONFIG = {
|
||||||
HIGHLIGHT: {
|
HIGHLIGHT: {
|
||||||
type: "highlight" as const,
|
type: "highlight" as const,
|
||||||
animationDuration: 2000,
|
animationDuration: 2000,
|
||||||
iterations: 3,
|
iterations: 3,
|
||||||
color: "#3b82f680",
|
color: "#3b82f680",
|
||||||
multiline: true,
|
multiline: true,
|
||||||
},
|
},
|
||||||
UNDERLINE: {
|
UNDERLINE: {
|
||||||
type: "underline" as const,
|
type: "underline" as const,
|
||||||
animationDuration: 2000,
|
animationDuration: 2000,
|
||||||
iterations: 3,
|
iterations: 3,
|
||||||
color: "#10b981",
|
color: "#10b981",
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// State management with useReducer for better organization
|
// State management with useReducer for better organization
|
||||||
interface HighlightState {
|
interface HighlightState {
|
||||||
shouldShowHighlight: boolean;
|
shouldShowHighlight: boolean;
|
||||||
layoutStable: boolean;
|
layoutStable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type HighlightAction =
|
type HighlightAction =
|
||||||
| { type: "SIDEBAR_CHANGED" }
|
| { type: "SIDEBAR_CHANGED" }
|
||||||
| { type: "LAYOUT_STABILIZED" }
|
| { type: "LAYOUT_STABILIZED" }
|
||||||
| { type: "SHOW_HIGHLIGHT" }
|
| { type: "SHOW_HIGHLIGHT" }
|
||||||
| { type: "HIDE_HIGHLIGHT" };
|
| { type: "HIDE_HIGHLIGHT" };
|
||||||
|
|
||||||
const highlightReducer = (
|
const highlightReducer = (state: HighlightState, action: HighlightAction): HighlightState => {
|
||||||
state: HighlightState,
|
switch (action.type) {
|
||||||
action: HighlightAction
|
case "SIDEBAR_CHANGED":
|
||||||
): HighlightState => {
|
return {
|
||||||
switch (action.type) {
|
shouldShowHighlight: false,
|
||||||
case "SIDEBAR_CHANGED":
|
layoutStable: false,
|
||||||
return {
|
};
|
||||||
shouldShowHighlight: false,
|
case "LAYOUT_STABILIZED":
|
||||||
layoutStable: false,
|
return {
|
||||||
};
|
...state,
|
||||||
case "LAYOUT_STABILIZED":
|
layoutStable: true,
|
||||||
return {
|
};
|
||||||
...state,
|
case "SHOW_HIGHLIGHT":
|
||||||
layoutStable: true,
|
return {
|
||||||
};
|
...state,
|
||||||
case "SHOW_HIGHLIGHT":
|
shouldShowHighlight: true,
|
||||||
return {
|
};
|
||||||
...state,
|
case "HIDE_HIGHLIGHT":
|
||||||
shouldShowHighlight: true,
|
return {
|
||||||
};
|
...state,
|
||||||
case "HIDE_HIGHLIGHT":
|
shouldShowHighlight: false,
|
||||||
return {
|
};
|
||||||
...state,
|
default:
|
||||||
shouldShowHighlight: false,
|
return state;
|
||||||
};
|
}
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState: HighlightState = {
|
const initialState: HighlightState = {
|
||||||
shouldShowHighlight: false,
|
shouldShowHighlight: false,
|
||||||
layoutStable: true,
|
layoutStable: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AnimatedEmptyState() {
|
export function AnimatedEmptyState() {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const isInView = useInView(ref);
|
const isInView = useInView(ref);
|
||||||
const { state: sidebarState } = useSidebar();
|
const { state: sidebarState } = useSidebar();
|
||||||
const [{ shouldShowHighlight, layoutStable }, dispatch] = useReducer(
|
const [{ shouldShowHighlight, layoutStable }, dispatch] = useReducer(
|
||||||
highlightReducer,
|
highlightReducer,
|
||||||
initialState
|
initialState
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoize class names to prevent unnecessary recalculations
|
// Memoize class names to prevent unnecessary recalculations
|
||||||
const headingClassName = useMemo(() => cn(
|
const headingClassName = useMemo(
|
||||||
"text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-neutral-900 dark:text-neutral-50 mb-6",
|
() =>
|
||||||
manrope.className,
|
cn(
|
||||||
), []);
|
"text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-neutral-900 dark:text-neutral-50 mb-6",
|
||||||
|
manrope.className
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const paragraphClassName = useMemo(() =>
|
const paragraphClassName = useMemo(
|
||||||
"text-lg sm:text-xl text-neutral-600 dark:text-neutral-300 mb-8 max-w-2xl mx-auto",
|
() => "text-lg sm:text-xl text-neutral-600 dark:text-neutral-300 mb-8 max-w-2xl mx-auto",
|
||||||
[]);
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
// Handle sidebar state changes
|
// Handle sidebar state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch({ type: "SIDEBAR_CHANGED" });
|
dispatch({ type: "SIDEBAR_CHANGED" });
|
||||||
|
|
||||||
const stabilizeTimer = setTimeout(() => {
|
const stabilizeTimer = setTimeout(() => {
|
||||||
dispatch({ type: "LAYOUT_STABILIZED" });
|
dispatch({ type: "LAYOUT_STABILIZED" });
|
||||||
}, TIMING.SIDEBAR_TRANSITION);
|
}, TIMING.SIDEBAR_TRANSITION);
|
||||||
|
|
||||||
return () => clearTimeout(stabilizeTimer);
|
return () => clearTimeout(stabilizeTimer);
|
||||||
}, [sidebarState]);
|
}, []);
|
||||||
|
|
||||||
// Handle highlight visibility based on layout stability and viewport visibility
|
// Handle highlight visibility based on layout stability and viewport visibility
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!layoutStable || !isInView) {
|
if (!layoutStable || !isInView) {
|
||||||
dispatch({ type: "HIDE_HIGHLIGHT" });
|
dispatch({ type: "HIDE_HIGHLIGHT" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showTimer = setTimeout(() => {
|
const showTimer = setTimeout(() => {
|
||||||
dispatch({ type: "SHOW_HIGHLIGHT" });
|
dispatch({ type: "SHOW_HIGHLIGHT" });
|
||||||
}, TIMING.LAYOUT_SETTLE);
|
}, TIMING.LAYOUT_SETTLE);
|
||||||
|
|
||||||
return () => clearTimeout(showTimer);
|
return () => clearTimeout(showTimer);
|
||||||
}, [layoutStable, isInView]);
|
}, [layoutStable, isInView]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={ref} className="flex-1 flex items-center justify-center w-full min-h-[400px]">
|
||||||
ref={ref}
|
<div className="max-w-4xl mx-auto px-4 py-10 text-center">
|
||||||
className="flex-1 flex items-center justify-center w-full min-h-[400px]"
|
<RoughNotationGroup show={shouldShowHighlight}>
|
||||||
>
|
<h1 className={headingClassName}>
|
||||||
<div className="max-w-4xl mx-auto px-4 py-10 text-center">
|
<RoughNotation {...ANIMATION_CONFIG.HIGHLIGHT}>
|
||||||
<RoughNotationGroup show={shouldShowHighlight}>
|
<span>SurfSense</span>
|
||||||
<h1 className={headingClassName}>
|
</RoughNotation>
|
||||||
<RoughNotation {...ANIMATION_CONFIG.HIGHLIGHT}>
|
</h1>
|
||||||
<span>SurfSense</span>
|
|
||||||
</RoughNotation>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className={paragraphClassName}>
|
<p className={paragraphClassName}>
|
||||||
<RoughNotation {...ANIMATION_CONFIG.UNDERLINE}>
|
<RoughNotation {...ANIMATION_CONFIG.UNDERLINE}>Let's Start Surfing</RoughNotation>{" "}
|
||||||
Let's Start Surfing
|
through your knowledge base.
|
||||||
</RoughNotation>{" "}
|
</p>
|
||||||
through your knowledge base.
|
</RoughNotationGroup>
|
||||||
</p>
|
</div>
|
||||||
</RoughNotationGroup>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
|
||||||
export const CitationDisplay: React.FC<{ index: number; node: any }> = ({
|
export const CitationDisplay: React.FC<{ index: number; node: any }> = ({ index, node }) => {
|
||||||
index,
|
|
||||||
node,
|
|
||||||
}) => {
|
|
||||||
const truncateText = (text: string, maxLength: number = 200) => {
|
const truncateText = (text: string, maxLength: number = 200) => {
|
||||||
if (text.length <= maxLength) return text;
|
if (text.length <= maxLength) return text;
|
||||||
return text.substring(0, maxLength) + "...";
|
return `${text.substring(0, maxLength)}...`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUrlClick = (e: React.MouseEvent, url: string) => {
|
const handleUrlClick = (e: React.MouseEvent, url: string) => {
|
||||||
|
|
@ -33,13 +27,15 @@ export const CitationDisplay: React.FC<{ index: number; node: any }> = ({
|
||||||
<PopoverContent className="w-80 p-4 space-y-3 relative" align="start">
|
<PopoverContent className="w-80 p-4 space-y-3 relative" align="start">
|
||||||
{/* External Link Button - Top Right */}
|
{/* External Link Button - Top Right */}
|
||||||
{node?.url && (
|
{node?.url && (
|
||||||
<button
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
onClick={(e) => handleUrlClick(e, node.url)}
|
onClick={(e) => handleUrlClick(e, node.url)}
|
||||||
className="absolute top-3 right-3 inline-flex items-center justify-center w-6 h-6 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
|
className="absolute top-3 right-3 inline-flex items-center justify-center w-6 h-6 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
|
||||||
title="Open in new tab"
|
title="Open in new tab"
|
||||||
>
|
>
|
||||||
<ExternalLink size={14} />
|
<ExternalLink size={14} />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Heading */}
|
{/* Heading */}
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,36 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { getAnnotationData, type Message, useChatUI } from "@llamaindex/chat-ui";
|
||||||
import { SuggestedQuestions } from "@llamaindex/chat-ui/widgets";
|
import { SuggestedQuestions } from "@llamaindex/chat-ui/widgets";
|
||||||
import { getAnnotationData, Message, useChatUI } from "@llamaindex/chat-ui";
|
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
|
|
||||||
export const ChatFurtherQuestions: React.FC<{ message: Message }> = ({
|
export const ChatFurtherQuestions: React.FC<{ message: Message }> = ({ message }) => {
|
||||||
message,
|
const annotations: string[][] = getAnnotationData(message, "FURTHER_QUESTIONS");
|
||||||
}) => {
|
const { append, requestData } = useChatUI();
|
||||||
const annotations: string[][] = getAnnotationData(
|
|
||||||
message,
|
|
||||||
"FURTHER_QUESTIONS",
|
|
||||||
);
|
|
||||||
const { append, requestData } = useChatUI();
|
|
||||||
|
|
||||||
if (annotations.length !== 1 || annotations[0].length === 0) {
|
if (annotations.length !== 1 || annotations[0].length === 0) {
|
||||||
return <></>;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion
|
<Accordion type="single" collapsible className="w-full border rounded-md bg-card shadow-sm">
|
||||||
type="single"
|
<AccordionItem value="suggested-questions" className="border-0">
|
||||||
collapsible
|
<AccordionTrigger className="px-4 py-3 text-sm font-medium text-foreground transition-colors">
|
||||||
className="w-full border rounded-md bg-card shadow-sm"
|
Further Suggested Questions
|
||||||
>
|
</AccordionTrigger>
|
||||||
<AccordionItem value="suggested-questions" className="border-0">
|
<AccordionContent className="px-4 pb-4 pt-0">
|
||||||
<AccordionTrigger className="px-4 py-3 text-sm font-medium text-foreground transition-colors">
|
<SuggestedQuestions
|
||||||
Further Suggested Questions
|
questions={annotations[0]}
|
||||||
</AccordionTrigger>
|
append={append}
|
||||||
<AccordionContent className="px-4 pb-4 pt-0">
|
requestData={requestData}
|
||||||
<SuggestedQuestions
|
/>
|
||||||
questions={annotations[0]}
|
</AccordionContent>
|
||||||
append={append}
|
</AccordionItem>
|
||||||
requestData={requestData}
|
</Accordion>
|
||||||
/>
|
);
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,24 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChatInput } from "@llamaindex/chat-ui";
|
import { ChatInput } from "@llamaindex/chat-ui";
|
||||||
import { FolderOpen, Check, Zap, Brain } from "lucide-react";
|
import { Brain, Check, FolderOpen, Zap } from "lucide-react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import React, { Suspense, useCallback, useState } from "react";
|
||||||
|
import type { ResearchMode } from "@/components/chat";
|
||||||
|
import {
|
||||||
|
ConnectorButton as ConnectorButtonComponent,
|
||||||
|
getConnectorIcon,
|
||||||
|
} from "@/components/chat/ConnectorComponents";
|
||||||
|
import { DocumentsDataTable } from "@/components/chat/DocumentsDataTable";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -18,19 +27,9 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { type Document, useDocuments } from "@/hooks/use-documents";
|
||||||
import { Suspense, useState, useCallback } from "react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { useDocuments, Document } from "@/hooks/use-documents";
|
|
||||||
import { DocumentsDataTable } from "@/components/chat/DocumentsDataTable";
|
|
||||||
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
|
||||||
import {
|
|
||||||
getConnectorIcon,
|
|
||||||
ConnectorButton as ConnectorButtonComponent,
|
|
||||||
} from "@/components/chat/ConnectorComponents";
|
|
||||||
import { ResearchMode } from "@/components/chat";
|
|
||||||
import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
|
import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||||
import React from "react";
|
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
|
||||||
|
|
||||||
const DocumentSelector = React.memo(
|
const DocumentSelector = React.memo(
|
||||||
({
|
({
|
||||||
|
|
@ -45,7 +44,7 @@ const DocumentSelector = React.memo(
|
||||||
|
|
||||||
const { documents, loading, isLoaded, fetchDocuments } = useDocuments(
|
const { documents, loading, isLoaded, fetchDocuments } = useDocuments(
|
||||||
Number(search_space_id),
|
Number(search_space_id),
|
||||||
true,
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
|
|
@ -55,24 +54,21 @@ const DocumentSelector = React.memo(
|
||||||
fetchDocuments();
|
fetchDocuments();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fetchDocuments, isLoaded],
|
[fetchDocuments, isLoaded]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectionChange = useCallback(
|
const handleSelectionChange = useCallback(
|
||||||
(documents: Document[]) => {
|
(documents: Document[]) => {
|
||||||
onSelectionChange?.(documents);
|
onSelectionChange?.(documents);
|
||||||
},
|
},
|
||||||
[onSelectionChange],
|
[onSelectionChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDone = useCallback(() => {
|
const handleDone = useCallback(() => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectedCount = React.useMemo(
|
const selectedCount = React.useMemo(() => selectedDocuments.length, [selectedDocuments.length]);
|
||||||
() => selectedDocuments.length,
|
|
||||||
[selectedDocuments.length],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
|
|
@ -90,9 +86,7 @@ const DocumentSelector = React.memo(
|
||||||
<DialogContent className="max-w-[95vw] md:max-w-5xl h-[90vh] md:h-[85vh] p-0 flex flex-col">
|
<DialogContent className="max-w-[95vw] md:max-w-5xl h-[90vh] md:h-[85vh] p-0 flex flex-col">
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="px-4 md:px-6 py-4 border-b flex-shrink-0">
|
<div className="px-4 md:px-6 py-4 border-b flex-shrink-0">
|
||||||
<DialogTitle className="text-lg md:text-xl">
|
<DialogTitle className="text-lg md:text-xl">Select Documents</DialogTitle>
|
||||||
Select Documents
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="mt-1 text-sm">
|
<DialogDescription className="mt-1 text-sm">
|
||||||
Choose documents to include in your research context
|
Choose documents to include in your research context
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|
@ -103,9 +97,7 @@ const DocumentSelector = React.memo(
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full mx-auto" />
|
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full mx-auto" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">Loading documents...</p>
|
||||||
Loading documents...
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : isLoaded ? (
|
) : isLoaded ? (
|
||||||
|
|
@ -121,7 +113,7 @@ const DocumentSelector = React.memo(
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
DocumentSelector.displayName = "DocumentSelector";
|
DocumentSelector.displayName = "DocumentSelector";
|
||||||
|
|
@ -146,7 +138,7 @@ const ConnectorSelector = React.memo(
|
||||||
fetchConnectors();
|
fetchConnectors();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fetchConnectors, isLoaded],
|
[fetchConnectors, isLoaded]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConnectorToggle = useCallback(
|
const handleConnectorToggle = useCallback(
|
||||||
|
|
@ -157,7 +149,7 @@ const ConnectorSelector = React.memo(
|
||||||
: [...selectedConnectors, connectorType];
|
: [...selectedConnectors, connectorType];
|
||||||
onSelectionChange?.(newSelection);
|
onSelectionChange?.(newSelection);
|
||||||
},
|
},
|
||||||
[selectedConnectors, onSelectionChange],
|
[selectedConnectors, onSelectionChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectAll = useCallback(() => {
|
const handleSelectAll = useCallback(() => {
|
||||||
|
|
@ -195,26 +187,17 @@ const ConnectorSelector = React.memo(
|
||||||
const isSelected = selectedConnectors.includes(connector.type);
|
const isSelected = selectedConnectors.includes(connector.type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Button
|
||||||
key={connector.id}
|
key={connector.id}
|
||||||
className={`flex items-center gap-2 p-2 rounded-md border cursor-pointer transition-colors ${
|
className={`flex items-center gap-2 p-2 rounded-md border cursor-pointer transition-colors`}
|
||||||
isSelected
|
|
||||||
? "border-primary bg-primary/10"
|
|
||||||
: "border-border hover:border-primary/50 hover:bg-muted"
|
|
||||||
}`}
|
|
||||||
onClick={() => handleConnectorToggle(connector.type)}
|
onClick={() => handleConnectorToggle(connector.type)}
|
||||||
role="checkbox"
|
variant={isSelected ? "default" : "outline"}
|
||||||
aria-checked={isSelected}
|
size="sm"
|
||||||
tabIndex={0}
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded-full bg-muted">
|
{getConnectorIcon(connector.type)}
|
||||||
{getConnectorIcon(connector.type)}
|
<span className="flex-1 text-sm font-medium">{connector.name}</span>
|
||||||
</div>
|
</Button>
|
||||||
<span className="flex-1 text-sm font-medium">
|
|
||||||
{connector.name}
|
|
||||||
</span>
|
|
||||||
{isSelected && <Check className="h-4 w-4 text-primary" />}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|
@ -231,7 +214,7 @@ const ConnectorSelector = React.memo(
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
ConnectorSelector.displayName = "ConnectorSelector";
|
ConnectorSelector.displayName = "ConnectorSelector";
|
||||||
|
|
@ -254,9 +237,7 @@ const SearchModeSelector = React.memo(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 sm:gap-2">
|
<div className="flex items-center gap-1 sm:gap-2">
|
||||||
<span className="text-xs text-muted-foreground hidden sm:block">
|
<span className="text-xs text-muted-foreground hidden sm:block">Scope:</span>
|
||||||
Scope:
|
|
||||||
</span>
|
|
||||||
<div className="flex rounded-md border border-border overflow-hidden">
|
<div className="flex rounded-md border border-border overflow-hidden">
|
||||||
<Button
|
<Button
|
||||||
variant={searchMode === "DOCUMENTS" ? "default" : "ghost"}
|
variant={searchMode === "DOCUMENTS" ? "default" : "ghost"}
|
||||||
|
|
@ -278,7 +259,7 @@ const SearchModeSelector = React.memo(
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
SearchModeSelector.displayName = "SearchModeSelector";
|
SearchModeSelector.displayName = "SearchModeSelector";
|
||||||
|
|
@ -295,7 +276,7 @@ const ResearchModeSelector = React.memo(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
onResearchModeChange?.(value as ResearchMode);
|
onResearchModeChange?.(value as ResearchMode);
|
||||||
},
|
},
|
||||||
[onResearchModeChange],
|
[onResearchModeChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoize mode options to prevent recreation
|
// Memoize mode options to prevent recreation
|
||||||
|
|
@ -318,14 +299,12 @@ const ResearchModeSelector = React.memo(
|
||||||
shortLabel: "Deeper",
|
shortLabel: "Deeper",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 sm:gap-2">
|
<div className="flex items-center gap-1 sm:gap-2">
|
||||||
<span className="text-xs text-muted-foreground hidden sm:block">
|
<span className="text-xs text-muted-foreground hidden sm:block">Mode:</span>
|
||||||
Mode:
|
|
||||||
</span>
|
|
||||||
<Select value={researchMode} onValueChange={handleValueChange}>
|
<Select value={researchMode} onValueChange={handleValueChange}>
|
||||||
<SelectTrigger className="w-auto min-w-[80px] sm:min-w-[120px] h-8 text-xs border-border bg-background hover:bg-muted/50 transition-colors duration-200 focus:ring-2 focus:ring-primary/20">
|
<SelectTrigger className="w-auto min-w-[80px] sm:min-w-[120px] h-8 text-xs border-border bg-background hover:bg-muted/50 transition-colors duration-200 focus:ring-2 focus:ring-primary/20">
|
||||||
<SelectValue placeholder="Mode" className="text-xs" />
|
<SelectValue placeholder="Mode" className="text-xs" />
|
||||||
|
|
@ -348,27 +327,21 @@ const ResearchModeSelector = React.memo(
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
ResearchModeSelector.displayName = "ResearchModeSelector";
|
ResearchModeSelector.displayName = "ResearchModeSelector";
|
||||||
|
|
||||||
const LLMSelector = React.memo(() => {
|
const LLMSelector = React.memo(() => {
|
||||||
const { llmConfigs, loading: llmLoading, error } = useLLMConfigs();
|
const { llmConfigs, loading: llmLoading, error } = useLLMConfigs();
|
||||||
const {
|
const { preferences, updatePreferences, loading: preferencesLoading } = useLLMPreferences();
|
||||||
preferences,
|
|
||||||
updatePreferences,
|
|
||||||
loading: preferencesLoading,
|
|
||||||
} = useLLMPreferences();
|
|
||||||
|
|
||||||
const isLoading = llmLoading || preferencesLoading;
|
const isLoading = llmLoading || preferencesLoading;
|
||||||
|
|
||||||
// Memoize the selected config to avoid repeated lookups
|
// Memoize the selected config to avoid repeated lookups
|
||||||
const selectedConfig = React.useMemo(() => {
|
const selectedConfig = React.useMemo(() => {
|
||||||
if (!preferences.fast_llm_id || !llmConfigs.length) return null;
|
if (!preferences.fast_llm_id || !llmConfigs.length) return null;
|
||||||
return (
|
return llmConfigs.find((config) => config.id === preferences.fast_llm_id) || null;
|
||||||
llmConfigs.find((config) => config.id === preferences.fast_llm_id) || null
|
|
||||||
);
|
|
||||||
}, [preferences.fast_llm_id, llmConfigs]);
|
}, [preferences.fast_llm_id, llmConfigs]);
|
||||||
|
|
||||||
// Memoize the display value for the trigger
|
// Memoize the display value for the trigger
|
||||||
|
|
@ -390,7 +363,7 @@ const LLMSelector = React.memo(() => {
|
||||||
const llmId = value ? parseInt(value, 10) : undefined;
|
const llmId = value ? parseInt(value, 10) : undefined;
|
||||||
updatePreferences({ fast_llm_id: llmId });
|
updatePreferences({ fast_llm_id: llmId });
|
||||||
},
|
},
|
||||||
[updatePreferences],
|
[updatePreferences]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Loading skeleton
|
// Loading skeleton
|
||||||
|
|
@ -432,9 +405,7 @@ const LLMSelector = React.memo(() => {
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Zap className="h-3 w-3 text-primary flex-shrink-0" />
|
<Zap className="h-3 w-3 text-primary flex-shrink-0" />
|
||||||
<SelectValue placeholder="Fast LLM" className="text-xs">
|
<SelectValue placeholder="Fast LLM" className="text-xs">
|
||||||
{displayValue || (
|
{displayValue || <span className="text-muted-foreground">Select LLM</span>}
|
||||||
<span className="text-muted-foreground">Select LLM</span>
|
|
||||||
)}
|
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</div>
|
</div>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -452,9 +423,7 @@ const LLMSelector = React.memo(() => {
|
||||||
<div className="mx-auto w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3">
|
<div className="mx-auto w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3">
|
||||||
<Brain className="h-5 w-5 text-muted-foreground" />
|
<Brain className="h-5 w-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-sm font-medium mb-1">
|
<h4 className="text-sm font-medium mb-1">No LLM configurations</h4>
|
||||||
No LLM configurations
|
|
||||||
</h4>
|
|
||||||
<p className="text-xs text-muted-foreground mb-3">
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
Configure AI models to get started
|
Configure AI models to get started
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -482,13 +451,8 @@ const LLMSelector = React.memo(() => {
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="font-medium text-sm truncate">
|
<span className="font-medium text-sm truncate">{config.name}</span>
|
||||||
{config.name}
|
<Badge variant="outline" className="text-xs px-1.5 py-0.5 flex-shrink-0">
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-xs px-1.5 py-0.5 flex-shrink-0"
|
|
||||||
>
|
|
||||||
{config.provider}
|
{config.provider}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -537,10 +501,8 @@ const CustomChatInputOptions = React.memo(
|
||||||
}) => {
|
}) => {
|
||||||
// Memoize the loading fallback to prevent recreation
|
// Memoize the loading fallback to prevent recreation
|
||||||
const loadingFallback = React.useMemo(
|
const loadingFallback = React.useMemo(
|
||||||
() => (
|
() => <div className="h-8 min-w-[100px] animate-pulse bg-muted rounded-md" />,
|
||||||
<div className="h-8 min-w-[100px] animate-pulse bg-muted rounded-md" />
|
[]
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -557,10 +519,7 @@ const CustomChatInputOptions = React.memo(
|
||||||
selectedConnectors={selectedConnectors}
|
selectedConnectors={selectedConnectors}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<SearchModeSelector
|
<SearchModeSelector searchMode={searchMode} onSearchModeChange={onSearchModeChange} />
|
||||||
searchMode={searchMode}
|
|
||||||
onSearchModeChange={onSearchModeChange}
|
|
||||||
/>
|
|
||||||
<ResearchModeSelector
|
<ResearchModeSelector
|
||||||
researchMode={researchMode}
|
researchMode={researchMode}
|
||||||
onResearchModeChange={onResearchModeChange}
|
onResearchModeChange={onResearchModeChange}
|
||||||
|
|
@ -568,7 +527,7 @@ const CustomChatInputOptions = React.memo(
|
||||||
<LLMSelector />
|
<LLMSelector />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
CustomChatInputOptions.displayName = "CustomChatInputOptions";
|
CustomChatInputOptions.displayName = "CustomChatInputOptions";
|
||||||
|
|
@ -611,7 +570,7 @@ export const ChatInputUI = React.memo(
|
||||||
/>
|
/>
|
||||||
</ChatInput>
|
</ChatInput>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
ChatInputUI.displayName = "ChatInputUI";
|
ChatInputUI.displayName = "ChatInputUI";
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import { type ChatHandler, ChatSection as LlamaIndexChatSection } from "@llamaindex/chat-ui";
|
||||||
import {
|
import type { ResearchMode } from "@/components/chat";
|
||||||
ChatSection as LlamaIndexChatSection,
|
|
||||||
ChatHandler,
|
|
||||||
} from "@llamaindex/chat-ui";
|
|
||||||
import { Document } from "@/hooks/use-documents";
|
|
||||||
import { ChatInputUI } from "@/components/chat/ChatInputGroup";
|
import { ChatInputUI } from "@/components/chat/ChatInputGroup";
|
||||||
import { ResearchMode } from "@/components/chat";
|
|
||||||
import { ChatMessagesUI } from "@/components/chat/ChatMessages";
|
import { ChatMessagesUI } from "@/components/chat/ChatMessages";
|
||||||
|
import type { Document } from "@/hooks/use-documents";
|
||||||
|
|
||||||
interface ChatInterfaceProps {
|
interface ChatInterfaceProps {
|
||||||
handler: ChatHandler;
|
handler: ChatHandler;
|
||||||
|
|
|
||||||
|
|
@ -1,87 +1,73 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import {
|
import {
|
||||||
ChatMessage as LlamaIndexChatMessage,
|
ChatMessage as LlamaIndexChatMessage,
|
||||||
ChatMessages as LlamaIndexChatMessages,
|
ChatMessages as LlamaIndexChatMessages,
|
||||||
Message,
|
type Message,
|
||||||
useChatUI,
|
useChatUI,
|
||||||
} from "@llamaindex/chat-ui";
|
} from "@llamaindex/chat-ui";
|
||||||
import TerminalDisplay from "@/components/chat/ChatTerminal";
|
import { useEffect, useRef } from "react";
|
||||||
import ChatSourcesDisplay from "@/components/chat/ChatSources";
|
import { AnimatedEmptyState } from "@/components/chat/AnimatedEmptyState";
|
||||||
import { CitationDisplay } from "@/components/chat/ChatCitation";
|
import { CitationDisplay } from "@/components/chat/ChatCitation";
|
||||||
import { ChatFurtherQuestions } from "@/components/chat/ChatFurtherQuestions";
|
import { ChatFurtherQuestions } from "@/components/chat/ChatFurtherQuestions";
|
||||||
import { AnimatedEmptyState } from "@/components/chat/AnimatedEmptyState";
|
import ChatSourcesDisplay from "@/components/chat/ChatSources";
|
||||||
|
import TerminalDisplay from "@/components/chat/ChatTerminal";
|
||||||
import { languageRenderers } from "@/components/chat/CodeBlock";
|
import { languageRenderers } from "@/components/chat/CodeBlock";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function ChatMessagesUI() {
|
export function ChatMessagesUI() {
|
||||||
const { messages } = useChatUI();
|
const { messages } = useChatUI();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LlamaIndexChatMessages className="flex-1">
|
<LlamaIndexChatMessages className="flex-1">
|
||||||
<LlamaIndexChatMessages.Empty>
|
<LlamaIndexChatMessages.Empty>
|
||||||
<AnimatedEmptyState />
|
<AnimatedEmptyState />
|
||||||
</LlamaIndexChatMessages.Empty>
|
</LlamaIndexChatMessages.Empty>
|
||||||
<LlamaIndexChatMessages.List className="p-4">
|
<LlamaIndexChatMessages.List className="p-4">
|
||||||
{messages.map((message, index) => (
|
{messages.map((message, index) => (
|
||||||
<ChatMessageUI
|
<ChatMessageUI
|
||||||
key={`Message-${index}`}
|
key={`Message-${index}`}
|
||||||
message={message}
|
message={message}
|
||||||
isLast={index === messages.length - 1}
|
isLast={index === messages.length - 1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</LlamaIndexChatMessages.List>
|
</LlamaIndexChatMessages.List>
|
||||||
<LlamaIndexChatMessages.Loading />
|
<LlamaIndexChatMessages.Loading />
|
||||||
</LlamaIndexChatMessages>
|
</LlamaIndexChatMessages>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatMessageUI({
|
function ChatMessageUI({ message, isLast }: { message: Message; isLast: boolean }) {
|
||||||
message,
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
isLast,
|
|
||||||
}: {
|
|
||||||
message: Message;
|
|
||||||
isLast: boolean;
|
|
||||||
}) {
|
|
||||||
const bottomRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLast && bottomRef.current) {
|
if (isLast && bottomRef.current) {
|
||||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||||
}
|
}
|
||||||
}, [message]);
|
}, [isLast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LlamaIndexChatMessage
|
<LlamaIndexChatMessage message={message} isLast={isLast} className="flex flex-col ">
|
||||||
message={message}
|
{message.role === "assistant" ? (
|
||||||
isLast={isLast}
|
<div className="flex-1 flex flex-col space-y-4">
|
||||||
className="flex flex-col "
|
<TerminalDisplay message={message} open={isLast} />
|
||||||
>
|
<ChatSourcesDisplay message={message} />
|
||||||
{message.role === "assistant" ? (
|
<LlamaIndexChatMessage.Content className="flex-1">
|
||||||
<div className="flex-1 flex flex-col space-y-4">
|
<LlamaIndexChatMessage.Content.Markdown
|
||||||
<TerminalDisplay message={message} open={isLast} />
|
citationComponent={CitationDisplay}
|
||||||
<ChatSourcesDisplay message={message} />
|
languageRenderers={languageRenderers}
|
||||||
<LlamaIndexChatMessage.Content className="flex-1">
|
/>
|
||||||
<LlamaIndexChatMessage.Content.Markdown
|
</LlamaIndexChatMessage.Content>
|
||||||
citationComponent={CitationDisplay}
|
<div ref={bottomRef} />
|
||||||
languageRenderers={languageRenderers}
|
<div className="flex flex-row justify-end gap-2">
|
||||||
/>
|
{isLast && <ChatFurtherQuestions message={message} />}
|
||||||
</LlamaIndexChatMessage.Content>
|
<LlamaIndexChatMessage.Actions className="flex-1 flex-col" />
|
||||||
<div ref={bottomRef} />
|
</div>
|
||||||
<div className="flex flex-row justify-end gap-2">
|
</div>
|
||||||
{isLast && <ChatFurtherQuestions message={message} />}
|
) : (
|
||||||
<LlamaIndexChatMessage.Actions className="flex-1 flex-col" />
|
<LlamaIndexChatMessage.Content className="flex-1">
|
||||||
</div>
|
<LlamaIndexChatMessage.Content.Markdown languageRenderers={languageRenderers} />
|
||||||
</div>
|
</LlamaIndexChatMessage.Content>
|
||||||
) : (
|
)}
|
||||||
<LlamaIndexChatMessage.Content className="flex-1">
|
</LlamaIndexChatMessage>
|
||||||
<LlamaIndexChatMessage.Content.Markdown
|
);
|
||||||
languageRenderers={languageRenderers}
|
|
||||||
/>
|
|
||||||
</LlamaIndexChatMessage.Content>
|
|
||||||
)}
|
|
||||||
</LlamaIndexChatMessage>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { getAnnotationData, type Message } from "@llamaindex/chat-ui";
|
||||||
|
import { IconBrandGithub } from "@tabler/icons-react";
|
||||||
|
import { ExternalLink, FileText, Globe } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { getAnnotationData, Message } from "@llamaindex/chat-ui";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -11,16 +15,6 @@ import {
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { ExternalLink, FileText, Globe } from "lucide-react";
|
|
||||||
import { IconBrandGithub } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
interface Source {
|
interface Source {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -50,10 +44,6 @@ interface SourceNode {
|
||||||
metadata: NodeMetadata;
|
metadata: NodeMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NodesResponse {
|
|
||||||
nodes: SourceNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSourceIcon(type: string) {
|
function getSourceIcon(type: string) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "USER_SELECTED_GITHUB_CONNECTOR":
|
case "USER_SELECTED_GITHUB_CONNECTOR":
|
||||||
|
|
@ -113,12 +103,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
||||||
const allNodes: SourceNode[] = [];
|
const allNodes: SourceNode[] = [];
|
||||||
|
|
||||||
annotations.forEach((item) => {
|
annotations.forEach((item) => {
|
||||||
if (
|
if (item && typeof item === "object" && "nodes" in item && Array.isArray(item.nodes)) {
|
||||||
item &&
|
|
||||||
typeof item === "object" &&
|
|
||||||
"nodes" in item &&
|
|
||||||
Array.isArray(item.nodes)
|
|
||||||
) {
|
|
||||||
allNodes.push(...item.nodes);
|
allNodes.push(...item.nodes);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -133,7 +118,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
||||||
acc[sourceType].push(node);
|
acc[sourceType].push(node);
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, SourceNode[]>,
|
{} as Record<string, SourceNode[]>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert grouped nodes to SourceGroup format
|
// Convert grouped nodes to SourceGroup format
|
||||||
|
|
@ -159,10 +144,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalSources = sourceGroups.reduce(
|
const totalSources = sourceGroups.reduce((acc, group) => acc + group.sources.length, 0);
|
||||||
(acc, group) => acc + group.sources.length,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
|
@ -176,10 +158,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
||||||
<DialogHeader className="flex-shrink-0">
|
<DialogHeader className="flex-shrink-0">
|
||||||
<DialogTitle>Sources</DialogTitle>
|
<DialogTitle>Sources</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Tabs
|
<Tabs defaultValue={sourceGroups[0]?.type} className="flex-1 flex flex-col min-h-0">
|
||||||
defaultValue={sourceGroups[0]?.type}
|
|
||||||
className="flex-1 flex flex-col min-h-0"
|
|
||||||
>
|
|
||||||
<div className="flex-shrink-0 w-full overflow-x-auto">
|
<div className="flex-shrink-0 w-full overflow-x-auto">
|
||||||
<TabsList className="flex w-max min-w-full">
|
<TabsList className="flex w-max min-w-full">
|
||||||
{sourceGroups.map((group) => (
|
{sourceGroups.map((group) => (
|
||||||
|
|
@ -189,13 +168,8 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
||||||
className="flex items-center gap-2 whitespace-nowrap px-3 md:px-4"
|
className="flex items-center gap-2 whitespace-nowrap px-3 md:px-4"
|
||||||
>
|
>
|
||||||
{getSourceIcon(group.type)}
|
{getSourceIcon(group.type)}
|
||||||
<span className="truncate max-w-[100px] md:max-w-none">
|
<span className="truncate max-w-[100px] md:max-w-none">{group.name}</span>
|
||||||
{group.name}
|
<Badge variant="secondary" className="ml-1 h-5 text-xs flex-shrink-0">
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="ml-1 h-5 text-xs flex-shrink-0"
|
|
||||||
>
|
|
||||||
{group.sources.length}
|
{group.sources.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|
@ -203,11 +177,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
{sourceGroups.map((group) => (
|
{sourceGroups.map((group) => (
|
||||||
<TabsContent
|
<TabsContent key={group.type} value={group.type} className="flex-1 min-h-0 mt-4">
|
||||||
key={group.type}
|
|
||||||
value={group.type}
|
|
||||||
className="flex-1 min-h-0 mt-4"
|
|
||||||
>
|
|
||||||
<div className="h-full overflow-y-auto pr-2">
|
<div className="h-full overflow-y-auto pr-2">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{group.sources.map((source) => (
|
{group.sources.map((source) => (
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue