mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-06 06:12:40 +02:00
Merge pull request #866 from AnishSarkar22/fix/docker-dev
fix: enhance docker build CI pipeline, update docker ports & docker docs
This commit is contained in:
commit
d41d1a1c7f
90 changed files with 2481 additions and 2054 deletions
|
|
@ -1,97 +0,0 @@
|
||||||
# Git
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.gitattributes
|
|
||||||
|
|
||||||
# Documentation
|
|
||||||
*.md
|
|
||||||
!README.md
|
|
||||||
docs/
|
|
||||||
CONTRIBUTING.md
|
|
||||||
CODE_OF_CONDUCT.md
|
|
||||||
LICENSE
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
.cursor/
|
|
||||||
|
|
||||||
# Node
|
|
||||||
**/node_modules/
|
|
||||||
**/.next/
|
|
||||||
**/dist/
|
|
||||||
**/.turbo/
|
|
||||||
**/.cache/
|
|
||||||
**/coverage/
|
|
||||||
|
|
||||||
# Python
|
|
||||||
**/__pycache__/
|
|
||||||
**/*.pyc
|
|
||||||
**/*.pyo
|
|
||||||
**/*.pyd
|
|
||||||
**/.Python
|
|
||||||
**/build/
|
|
||||||
**/develop-eggs/
|
|
||||||
**/downloads/
|
|
||||||
**/eggs/
|
|
||||||
**/.eggs/
|
|
||||||
# Python venv lib folders (but not frontend lib folders)
|
|
||||||
surfsense_backend/lib/
|
|
||||||
surfsense_backend/lib64/
|
|
||||||
**/parts/
|
|
||||||
**/sdist/
|
|
||||||
**/var/
|
|
||||||
**/wheels/
|
|
||||||
**/*.egg-info/
|
|
||||||
**/.installed.cfg
|
|
||||||
**/*.egg
|
|
||||||
**/pip-log.txt
|
|
||||||
**/.tox/
|
|
||||||
**/.coverage
|
|
||||||
**/htmlcov/
|
|
||||||
**/.pytest_cache/
|
|
||||||
**/nosetests.xml
|
|
||||||
**/coverage.xml
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
**/.env
|
|
||||||
**/.env.*
|
|
||||||
!**/.env.example
|
|
||||||
**/*.local
|
|
||||||
|
|
||||||
# Docker
|
|
||||||
**/Dockerfile
|
|
||||||
**/docker-compose*.yml
|
|
||||||
**/.docker/
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
**/tests/
|
|
||||||
**/test/
|
|
||||||
**/__tests__/
|
|
||||||
**/*.test.*
|
|
||||||
**/*.spec.*
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
**/*.log
|
|
||||||
|
|
||||||
# Temporary files
|
|
||||||
**/tmp/
|
|
||||||
**/temp/
|
|
||||||
**/.tmp/
|
|
||||||
**/.temp/
|
|
||||||
|
|
||||||
# Build artifacts from backend
|
|
||||||
surfsense_backend/podcasts/
|
|
||||||
surfsense_backend/temp_audio/
|
|
||||||
surfsense_backend/*.bak
|
|
||||||
surfsense_backend/*.dat
|
|
||||||
surfsense_backend/*.dir
|
|
||||||
|
|
||||||
# GitHub
|
|
||||||
.github/
|
|
||||||
|
|
||||||
# Browser extension (not needed for main deployment)
|
|
||||||
surfsense_browser_extension/
|
|
||||||
|
|
||||||
|
|
@ -26,6 +26,7 @@ permissions:
|
||||||
jobs:
|
jobs:
|
||||||
tag_release:
|
tag_release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event_name == 'workflow_dispatch'
|
||||||
outputs:
|
outputs:
|
||||||
new_tag: ${{ steps.tag_version.outputs.next_version }}
|
new_tag: ${{ steps.tag_version.outputs.next_version }}
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -86,6 +87,7 @@ jobs:
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: tag_release
|
needs: tag_release
|
||||||
|
if: always() && (needs.tag_release.result == 'success' || needs.tag_release.result == 'skipped')
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
|
|
@ -121,6 +123,12 @@ jobs:
|
||||||
id: image
|
id: image
|
||||||
run: echo "name=${REGISTRY_IMAGE,,}" >> $GITHUB_OUTPUT
|
run: echo "name=${REGISTRY_IMAGE,,}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ steps.image.outputs.name }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
|
|
@ -139,14 +147,15 @@ jobs:
|
||||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY" || true
|
sudo rm -rf "$AGENT_TOOLSDIRECTORY" || true
|
||||||
docker system prune -af
|
docker system prune -af
|
||||||
|
|
||||||
- name: Build and push ${{ matrix.name }} (${{ matrix.suffix }})
|
- name: Build and push by digest ${{ matrix.name }} (${{ matrix.suffix }})
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ${{ matrix.context }}
|
context: ${{ matrix.context }}
|
||||||
file: ${{ matrix.file }}
|
file: ${{ matrix.file }}
|
||||||
push: true
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
tags: ${{ steps.image.outputs.name }}:${{ needs.tag_release.outputs.new_tag }}-${{ matrix.suffix }}
|
tags: ${{ steps.image.outputs.name }}
|
||||||
|
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
cache-from: type=gha,scope=${{ matrix.image }}-${{ matrix.suffix }}
|
cache-from: type=gha,scope=${{ matrix.image }}-${{ matrix.suffix }}
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.image }}-${{ matrix.suffix }}
|
cache-to: type=gha,mode=max,scope=${{ matrix.image }}-${{ matrix.suffix }}
|
||||||
|
|
@ -159,9 +168,24 @@ jobs:
|
||||||
${{ matrix.image == 'web' && 'NEXT_PUBLIC_ELECTRIC_AUTH_MODE=__NEXT_PUBLIC_ELECTRIC_AUTH_MODE__' || '' }}
|
${{ matrix.image == 'web' && 'NEXT_PUBLIC_ELECTRIC_AUTH_MODE=__NEXT_PUBLIC_ELECTRIC_AUTH_MODE__' || '' }}
|
||||||
${{ matrix.image == 'web' && 'NEXT_PUBLIC_DEPLOYMENT_MODE=__NEXT_PUBLIC_DEPLOYMENT_MODE__' || '' }}
|
${{ matrix.image == 'web' && 'NEXT_PUBLIC_DEPLOYMENT_MODE=__NEXT_PUBLIC_DEPLOYMENT_MODE__' || '' }}
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ matrix.image }}-${{ matrix.suffix }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
create_manifest:
|
create_manifest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [tag_release, build]
|
needs: [tag_release, build]
|
||||||
|
if: always() && needs.build.result == 'success'
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
contents: read
|
contents: read
|
||||||
|
|
@ -170,7 +194,9 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- name: surfsense-backend
|
- name: surfsense-backend
|
||||||
|
image: backend
|
||||||
- name: surfsense-web
|
- name: surfsense-web
|
||||||
|
image: web
|
||||||
env:
|
env:
|
||||||
REGISTRY_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.name }}
|
REGISTRY_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ matrix.name }}
|
||||||
|
|
||||||
|
|
@ -179,6 +205,21 @@ jobs:
|
||||||
id: image
|
id: image
|
||||||
run: echo "name=${REGISTRY_IMAGE,,}" >> $GITHUB_OUTPUT
|
run: echo "name=${REGISTRY_IMAGE,,}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Download amd64 digest
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ matrix.image }}-amd64
|
||||||
|
path: /tmp/digests
|
||||||
|
|
||||||
|
- name: Download arm64 digest
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ matrix.image }}-arm64
|
||||||
|
path: /tmp/digests
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
|
|
@ -186,35 +227,41 @@ jobs:
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create and push multi-arch manifest
|
- name: Compute app version
|
||||||
|
id: appver
|
||||||
run: |
|
run: |
|
||||||
VERSION_TAG="${{ needs.tag_release.outputs.new_tag }}"
|
VERSION_TAG="${{ needs.tag_release.outputs.new_tag }}"
|
||||||
IMAGE="${{ steps.image.outputs.name }}"
|
if [ -n "$VERSION_TAG" ]; then
|
||||||
APP_VERSION=$(echo "$VERSION_TAG" | rev | cut -d. -f2- | rev)
|
APP_VERSION=$(echo "$VERSION_TAG" | rev | cut -d. -f2- | rev)
|
||||||
|
else
|
||||||
docker manifest create ${IMAGE}:${VERSION_TAG} \
|
APP_VERSION=""
|
||||||
${IMAGE}:${VERSION_TAG}-amd64 \
|
|
||||||
${IMAGE}:${VERSION_TAG}-arm64
|
|
||||||
|
|
||||||
docker manifest push ${IMAGE}:${VERSION_TAG}
|
|
||||||
|
|
||||||
if [[ "${{ github.ref }}" == "refs/heads/${{ github.event.repository.default_branch }}" ]] || [[ "${{ github.event.inputs.branch }}" == "${{ github.event.repository.default_branch }}" ]]; then
|
|
||||||
docker manifest create ${IMAGE}:${APP_VERSION} \
|
|
||||||
${IMAGE}:${VERSION_TAG}-amd64 \
|
|
||||||
${IMAGE}:${VERSION_TAG}-arm64
|
|
||||||
|
|
||||||
docker manifest push ${IMAGE}:${APP_VERSION}
|
|
||||||
|
|
||||||
docker manifest create ${IMAGE}:latest \
|
|
||||||
${IMAGE}:${VERSION_TAG}-amd64 \
|
|
||||||
${IMAGE}:${VERSION_TAG}-arm64
|
|
||||||
|
|
||||||
docker manifest push ${IMAGE}:latest
|
|
||||||
fi
|
fi
|
||||||
|
echo "app_version=$APP_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ steps.image.outputs.name }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=${{ needs.tag_release.outputs.new_tag }},enable=${{ needs.tag_release.outputs.new_tag != '' }}
|
||||||
|
type=raw,value=${{ steps.appver.outputs.app_version }},enable=${{ needs.tag_release.outputs.new_tag != '' && (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event.inputs.branch == github.event.repository.default_branch) }}
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha,prefix=git-
|
||||||
|
flavor: |
|
||||||
|
latest=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || github.event.inputs.branch == github.event.repository.default_branch }}
|
||||||
|
|
||||||
|
- name: Create manifest list and push
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create \
|
||||||
|
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf '${{ steps.image.outputs.name }}@sha256:%s ' *)
|
||||||
|
- name: Inspect image
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect ${{ steps.image.outputs.name }}:${{ steps.meta.outputs.version }}
|
||||||
|
|
||||||
- name: Summary
|
- name: Summary
|
||||||
run: |
|
run: |
|
||||||
echo "Multi-arch manifest created for ${{ matrix.name }}!"
|
echo "Multi-arch manifest created for ${{ matrix.name }}!"
|
||||||
echo "Versioned: ${{ steps.image.outputs.name }}:${{ needs.tag_release.outputs.new_tag }}"
|
echo "Tags: $(jq -cr '.tags | join(", ")' <<< "$DOCKER_METADATA_OUTPUT_JSON")"
|
||||||
echo "App version: ${{ steps.image.outputs.name }}:$(echo '${{ needs.tag_release.outputs.new_tag }}' | rev | cut -d. -f2- | rev)"
|
|
||||||
echo "Latest: ${{ steps.image.outputs.name }}:latest"
|
|
||||||
|
|
@ -33,9 +33,9 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
||||||
# Ports (change to avoid conflicts with other services on your machine)
|
# Ports (change to avoid conflicts with other services on your machine)
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
# BACKEND_PORT=8000
|
# BACKEND_PORT=8929
|
||||||
# FRONTEND_PORT=3000
|
# FRONTEND_PORT=3929
|
||||||
# ELECTRIC_PORT=5133
|
# ELECTRIC_PORT=5929
|
||||||
# FLOWER_PORT=5555
|
# FLOWER_PORT=5555
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
# For production with prebuilt images, use docker/docker-compose.yml instead.
|
# For production with prebuilt images, use docker/docker-compose.yml instead.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
name: surfsense
|
name: surfsense-dev
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
|
|
@ -162,8 +162,9 @@ services:
|
||||||
image: electricsql/electric:1.4.10
|
image: electricsql/electric:1.4.10
|
||||||
ports:
|
ports:
|
||||||
- "${ELECTRIC_PORT:-5133}:3000"
|
- "${ELECTRIC_PORT:-5133}:3000"
|
||||||
# depends_on:
|
depends_on:
|
||||||
# - db
|
db:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=${ELECTRIC_DATABASE_URL:-postgresql://${ELECTRIC_DB_USER:-electric}:${ELECTRIC_DB_PASSWORD:-electric_password}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
|
- DATABASE_URL=${ELECTRIC_DATABASE_URL:-postgresql://${ELECTRIC_DB_USER:-electric}:${ELECTRIC_DB_PASSWORD:-electric_password}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
|
||||||
- ELECTRIC_INSECURE=true
|
- ELECTRIC_INSECURE=true
|
||||||
|
|
@ -197,10 +198,10 @@ services:
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
name: surfsense-postgres
|
name: surfsense-dev-postgres
|
||||||
pgadmin_data:
|
pgadmin_data:
|
||||||
name: surfsense-pgadmin
|
name: surfsense-dev-pgadmin
|
||||||
redis_data:
|
redis_data:
|
||||||
name: surfsense-redis
|
name: surfsense-dev-redis
|
||||||
shared_temp:
|
shared_temp:
|
||||||
name: surfsense-shared-temp
|
name: surfsense-dev-shared-temp
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ services:
|
||||||
backend:
|
backend:
|
||||||
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}
|
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-8000}:8000"
|
- "${BACKEND_PORT:-8929}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- shared_temp:/shared_tmp
|
- shared_temp:/shared_tmp
|
||||||
env_file:
|
env_file:
|
||||||
|
|
@ -61,7 +61,7 @@ services:
|
||||||
UNSTRUCTURED_HAS_PATCHED_LOOP: "1"
|
UNSTRUCTURED_HAS_PATCHED_LOOP: "1"
|
||||||
ELECTRIC_DB_USER: ${ELECTRIC_DB_USER:-electric}
|
ELECTRIC_DB_USER: ${ELECTRIC_DB_USER:-electric}
|
||||||
ELECTRIC_DB_PASSWORD: ${ELECTRIC_DB_PASSWORD:-electric_password}
|
ELECTRIC_DB_PASSWORD: ${ELECTRIC_DB_PASSWORD:-electric_password}
|
||||||
NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-http://localhost:${FRONTEND_PORT:-3000}}
|
NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-http://localhost:${FRONTEND_PORT:-3929}}
|
||||||
# Daytona Sandbox – uncomment and set credentials to enable cloud code execution
|
# Daytona Sandbox – uncomment and set credentials to enable cloud code execution
|
||||||
# DAYTONA_SANDBOX_ENABLED: "TRUE"
|
# DAYTONA_SANDBOX_ENABLED: "TRUE"
|
||||||
# DAYTONA_API_KEY: ${DAYTONA_API_KEY:-}
|
# DAYTONA_API_KEY: ${DAYTONA_API_KEY:-}
|
||||||
|
|
@ -151,7 +151,7 @@ services:
|
||||||
electric:
|
electric:
|
||||||
image: electricsql/electric:1.4.10
|
image: electricsql/electric:1.4.10
|
||||||
ports:
|
ports:
|
||||||
- "${ELECTRIC_PORT:-5133}:3000"
|
- "${ELECTRIC_PORT:-5929}:3000"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${ELECTRIC_DATABASE_URL:-postgresql://${ELECTRIC_DB_USER:-electric}:${ELECTRIC_DB_PASSWORD:-electric_password}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
|
DATABASE_URL: ${ELECTRIC_DATABASE_URL:-postgresql://${ELECTRIC_DB_USER:-electric}:${ELECTRIC_DB_PASSWORD:-electric_password}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
|
||||||
ELECTRIC_INSECURE: "true"
|
ELECTRIC_INSECURE: "true"
|
||||||
|
|
@ -169,10 +169,10 @@ services:
|
||||||
frontend:
|
frontend:
|
||||||
image: ghcr.io/modsetter/surfsense-web:${SURFSENSE_VERSION:-latest}
|
image: ghcr.io/modsetter/surfsense-web:${SURFSENSE_VERSION:-latest}
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-3000}:3000"
|
- "${FRONTEND_PORT:-3929}:3000"
|
||||||
environment:
|
environment:
|
||||||
NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL:-http://localhost:${BACKEND_PORT:-8000}}
|
NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL:-http://localhost:${BACKEND_PORT:-8929}}
|
||||||
NEXT_PUBLIC_ELECTRIC_URL: ${NEXT_PUBLIC_ELECTRIC_URL:-http://localhost:${ELECTRIC_PORT:-5133}}
|
NEXT_PUBLIC_ELECTRIC_URL: ${NEXT_PUBLIC_ELECTRIC_URL:-http://localhost:${ELECTRIC_PORT:-5929}}
|
||||||
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-LOCAL}
|
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-LOCAL}
|
||||||
NEXT_PUBLIC_ETL_SERVICE: ${ETL_SERVICE:-DOCLING}
|
NEXT_PUBLIC_ETL_SERVICE: ${ETL_SERVICE:-DOCLING}
|
||||||
NEXT_PUBLIC_DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted}
|
NEXT_PUBLIC_DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted}
|
||||||
|
|
|
||||||
|
|
@ -321,9 +321,9 @@ Write-Host " OSS Alternative to NotebookLM for Teams [$versionDisplay]"
|
||||||
Write-Host ("=" * 62) -ForegroundColor Cyan
|
Write-Host ("=" * 62) -ForegroundColor Cyan
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
Write-Info " Frontend: http://localhost:3000"
|
Write-Info " Frontend: http://localhost:3929"
|
||||||
Write-Info " Backend: http://localhost:8000"
|
Write-Info " Backend: http://localhost:8929"
|
||||||
Write-Info " API Docs: http://localhost:8000/docs"
|
Write-Info " API Docs: http://localhost:8929/docs"
|
||||||
Write-Info ""
|
Write-Info ""
|
||||||
Write-Info " Config: $InstallDir\.env"
|
Write-Info " Config: $InstallDir\.env"
|
||||||
Write-Info " Logs: cd $InstallDir; docker compose logs -f"
|
Write-Info " Logs: cd $InstallDir; docker compose logs -f"
|
||||||
|
|
|
||||||
|
|
@ -304,9 +304,9 @@ _version_display="${_version_display:-latest}"
|
||||||
printf " OSS Alternative to NotebookLM for Teams ${YELLOW}[%s]${NC}\n" "${_version_display}"
|
printf " OSS Alternative to NotebookLM for Teams ${YELLOW}[%s]${NC}\n" "${_version_display}"
|
||||||
printf "${CYAN}══════════════════════════════════════════════════════════════${NC}\n\n"
|
printf "${CYAN}══════════════════════════════════════════════════════════════${NC}\n\n"
|
||||||
|
|
||||||
info " Frontend: http://localhost:3000"
|
info " Frontend: http://localhost:3929"
|
||||||
info " Backend: http://localhost:8000"
|
info " Backend: http://localhost:8929"
|
||||||
info " API Docs: http://localhost:8000/docs"
|
info " API Docs: http://localhost:8929/docs"
|
||||||
info ""
|
info ""
|
||||||
info " Config: ${INSTALL_DIR}/.env"
|
info " Config: ${INSTALL_DIR}/.env"
|
||||||
info " Logs: cd ${INSTALL_DIR} && ${DC} logs -f"
|
info " Logs: cd ${INSTALL_DIR} && ${DC} logs -f"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,14 @@ import { useEffect } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -108,37 +115,26 @@ export default function MorePagesPage() {
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
|
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
|
||||||
task.completed
|
task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-muted"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{task.completed ? (
|
{task.completed ? <Check className="h-4 w-4" /> : <Star className="h-4 w-4" />}
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Star className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-medium",
|
"text-sm font-medium",
|
||||||
task.completed &&
|
task.completed && "text-muted-foreground line-through"
|
||||||
"text-muted-foreground line-through"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{task.title}
|
{task.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">+{task.pages_reward} pages</p>
|
||||||
+{task.pages_reward} pages
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant={task.completed ? "ghost" : "outline"}
|
variant={task.completed ? "ghost" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={
|
disabled={task.completed || completeMutation.isPending}
|
||||||
task.completed || completeMutation.isPending
|
|
||||||
}
|
|
||||||
onClick={() => handleTaskClick(task)}
|
onClick={() => handleTaskClick(task)}
|
||||||
asChild={!task.completed}
|
asChild={!task.completed}
|
||||||
>
|
>
|
||||||
|
|
@ -181,8 +177,9 @@ export default function MorePagesPage() {
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
For a limited time, get <span className="font-semibold text-foreground">6,000 additional pages</span> at
|
For a limited time, get{" "}
|
||||||
no cost. Contact us and we'll upgrade your account instantly.
|
<span className="font-semibold text-foreground">6,000 additional pages</span> at no
|
||||||
|
cost. Contact us and we'll upgrade your account instantly.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter className="pt-2">
|
<CardFooter className="pt-2">
|
||||||
|
|
@ -196,9 +193,7 @@ export default function MorePagesPage() {
|
||||||
<DialogContent className="select-none sm:max-w-sm">
|
<DialogContent className="select-none sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Get in Touch</DialogTitle>
|
<DialogTitle>Get in Touch</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>Pick the option that works best for you.</DialogDescription>
|
||||||
Pick the option that works best for you.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { Thread } from "@/components/assistant-ui/thread";
|
import { Thread } from "@/components/assistant-ui/thread";
|
||||||
import { ReportPanel } from "@/components/report-panel/report-panel";
|
import { MobileReportPanel } from "@/components/report-panel/report-panel";
|
||||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||||
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
||||||
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
||||||
|
|
@ -405,7 +405,6 @@ export default function NewChatPage() {
|
||||||
id: currentThread?.id ?? null,
|
id: currentThread?.id ?? null,
|
||||||
visibility: currentThread?.visibility ?? null,
|
visibility: currentThread?.visibility ?? null,
|
||||||
hasComments: currentThread?.has_comments ?? false,
|
hasComments: currentThread?.has_comments ?? false,
|
||||||
addingCommentToMessageId: null,
|
|
||||||
}));
|
}));
|
||||||
}, [currentThread, setCurrentThreadState]);
|
}, [currentThread, setCurrentThreadState]);
|
||||||
|
|
||||||
|
|
@ -1669,7 +1668,7 @@ export default function NewChatPage() {
|
||||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
<Thread messageThinkingSteps={messageThinkingSteps} />
|
<Thread messageThinkingSteps={messageThinkingSteps} />
|
||||||
</div>
|
</div>
|
||||||
<ReportPanel />
|
<MobileReportPanel />
|
||||||
</div>
|
</div>
|
||||||
</AssistantRuntimeProvider>
|
</AssistantRuntimeProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
||||||
|
import Image from "next/image";
|
||||||
export const baseOptions: BaseLayoutProps = {
|
export const baseOptions: BaseLayoutProps = {
|
||||||
nav: {
|
nav: {
|
||||||
title: "SurfSense Docs",
|
title: (
|
||||||
|
<>
|
||||||
|
<Image src="/icon-128.svg" alt="SurfSense" width={24} height={24} className="dark:invert" />
|
||||||
|
SurfSense Docs
|
||||||
|
</>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
githubUrl: "https://github.com/MODSetter/SurfSense",
|
githubUrl: "https://github.com/MODSetter/SurfSense",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,37 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: "https://www.surfsense.com/docs/docker-installation",
|
url: "https://www.surfsense.com/docs/prerequisites",
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: "daily",
|
||||||
|
priority: 0.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.surfsense.com/docs/docker-installation/install-script",
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: "daily",
|
||||||
|
priority: 0.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.surfsense.com/docs/docker-installation/docker-compose",
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: "daily",
|
||||||
|
priority: 0.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.surfsense.com/docs/docker-installation/updating",
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: "daily",
|
||||||
|
priority: 0.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.surfsense.com/docs/docker-installation/dev-compose",
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: "daily",
|
||||||
|
priority: 0.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.surfsense.com/docs/docker-installation/migrate-from-allinone",
|
||||||
lastModified,
|
lastModified,
|
||||||
changeFrequency: "daily",
|
changeFrequency: "daily",
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
|
|
@ -163,6 +193,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
changeFrequency: "daily",
|
changeFrequency: "daily",
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.surfsense.com/docs/connectors/obsidian",
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: "daily",
|
||||||
|
priority: 0.8,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
url: "https://www.surfsense.com/docs/connectors/slack",
|
url: "https://www.surfsense.com/docs/connectors/slack",
|
||||||
lastModified,
|
lastModified,
|
||||||
|
|
@ -182,5 +218,24 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
changeFrequency: "daily",
|
changeFrequency: "daily",
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.surfsense.com/docs/how-to/realtime-collaboration",
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: "daily",
|
||||||
|
priority: 0.8,
|
||||||
|
},
|
||||||
|
// Developer documentation
|
||||||
|
{
|
||||||
|
url: "https://www.surfsense.com/docs/testing",
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: "daily",
|
||||||
|
priority: 0.7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.surfsense.com/docs/code-of-conduct",
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: "daily",
|
||||||
|
priority: 0.7,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,17 @@
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
|
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
|
||||||
import { reportPanelAtom, reportPanelOpenAtom } from "./report-panel.atom";
|
import { reportPanelAtom } from "./report-panel.atom";
|
||||||
|
|
||||||
// TODO: Update `hasComments` to true when the first comment is created on a thread.
|
|
||||||
// Currently it only updates on thread load. The gutter still works because
|
|
||||||
// `addingCommentToMessageId` keeps it open, but the state is technically stale.
|
|
||||||
|
|
||||||
// TODO: Reset `addingCommentToMessageId` to null after a comment is successfully created.
|
|
||||||
// Currently it stays set until navigation or clicking another message's bubble.
|
|
||||||
// Not causing issues since panel visibility is driven by per-message comment count.
|
|
||||||
|
|
||||||
// TODO: Consider calling `resetCurrentThreadAtom` when unmounting the chat page
|
|
||||||
// for explicit cleanup, though React navigation handles this implicitly.
|
|
||||||
|
|
||||||
interface CurrentThreadState {
|
interface CurrentThreadState {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
visibility: ChatVisibility | null;
|
visibility: ChatVisibility | null;
|
||||||
hasComments: boolean;
|
hasComments: boolean;
|
||||||
addingCommentToMessageId: number | null;
|
|
||||||
/** Whether the right-side comments panel is collapsed (desktop only) */
|
|
||||||
commentsCollapsed: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: CurrentThreadState = {
|
const initialState: CurrentThreadState = {
|
||||||
id: null,
|
id: null,
|
||||||
visibility: null,
|
visibility: null,
|
||||||
hasComments: false,
|
hasComments: false,
|
||||||
addingCommentToMessageId: null,
|
|
||||||
commentsCollapsed: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const currentThreadAtom = atom<CurrentThreadState>(initialState);
|
export const currentThreadAtom = atom<CurrentThreadState>(initialState);
|
||||||
|
|
@ -36,63 +20,22 @@ export const commentsEnabledAtom = atom(
|
||||||
(get) => get(currentThreadAtom).visibility === "SEARCH_SPACE"
|
(get) => get(currentThreadAtom).visibility === "SEARCH_SPACE"
|
||||||
);
|
);
|
||||||
|
|
||||||
export const showCommentsGutterAtom = atom((get) => {
|
|
||||||
const thread = get(currentThreadAtom);
|
|
||||||
// Hide gutter if comments are collapsed
|
|
||||||
if (thread.commentsCollapsed) return false;
|
|
||||||
// Hide gutter if report panel is open (report panel takes the right side)
|
|
||||||
if (get(reportPanelOpenAtom)) return false;
|
|
||||||
return (
|
|
||||||
thread.visibility === "SEARCH_SPACE" &&
|
|
||||||
(thread.hasComments || thread.addingCommentToMessageId !== null)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export const addingCommentToMessageIdAtom = atom(
|
|
||||||
(get) => get(currentThreadAtom).addingCommentToMessageId,
|
|
||||||
(get, set, messageId: number | null) => {
|
|
||||||
set(currentThreadAtom, { ...get(currentThreadAtom), addingCommentToMessageId: messageId });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Setter atom for updating thread visibility
|
|
||||||
export const setThreadVisibilityAtom = atom(null, (get, set, newVisibility: ChatVisibility) => {
|
export const setThreadVisibilityAtom = atom(null, (get, set, newVisibility: ChatVisibility) => {
|
||||||
set(currentThreadAtom, { ...get(currentThreadAtom), visibility: newVisibility });
|
set(currentThreadAtom, { ...get(currentThreadAtom), visibility: newVisibility });
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resetCurrentThreadAtom = atom(null, (_, set) => {
|
export const resetCurrentThreadAtom = atom(null, (_, set) => {
|
||||||
set(currentThreadAtom, initialState);
|
set(currentThreadAtom, initialState);
|
||||||
// Also close the report panel when resetting the thread
|
|
||||||
set(reportPanelAtom, { isOpen: false, reportId: null, title: null, wordCount: null });
|
set(reportPanelAtom, { isOpen: false, reportId: null, title: null, wordCount: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Atom to read whether comments panel is collapsed */
|
|
||||||
export const commentsCollapsedAtom = atom((get) => get(currentThreadAtom).commentsCollapsed);
|
|
||||||
|
|
||||||
/** Atom to toggle the comments collapsed state */
|
|
||||||
export const toggleCommentsCollapsedAtom = atom(null, (get, set) => {
|
|
||||||
const current = get(currentThreadAtom);
|
|
||||||
set(currentThreadAtom, { ...current, commentsCollapsed: !current.commentsCollapsed });
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Atom to explicitly set the comments collapsed state */
|
|
||||||
export const setCommentsCollapsedAtom = atom(null, (get, set, collapsed: boolean) => {
|
|
||||||
set(currentThreadAtom, { ...get(currentThreadAtom), commentsCollapsed: collapsed });
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Target comment ID to scroll to (from URL navigation or inbox click) */
|
/** Target comment ID to scroll to (from URL navigation or inbox click) */
|
||||||
export const targetCommentIdAtom = atom<number | null>(null);
|
export const targetCommentIdAtom = atom<number | null>(null);
|
||||||
|
|
||||||
/** Setter for target comment ID - also ensures comments are not collapsed */
|
export const setTargetCommentIdAtom = atom(null, (_, set, commentId: number | null) => {
|
||||||
export const setTargetCommentIdAtom = atom(null, (get, set, commentId: number | null) => {
|
|
||||||
// Ensure comments are not collapsed when navigating to a comment
|
|
||||||
if (commentId !== null) {
|
|
||||||
set(currentThreadAtom, { ...get(currentThreadAtom), commentsCollapsed: false });
|
|
||||||
}
|
|
||||||
set(targetCommentIdAtom, commentId);
|
set(targetCommentIdAtom, commentId);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Clear target after navigation completes */
|
|
||||||
export const clearTargetCommentIdAtom = atom(null, (_, set) => {
|
export const clearTargetCommentIdAtom = atom(null, (_, set) => {
|
||||||
set(targetCommentIdAtom, null);
|
set(targetCommentIdAtom, null);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
|
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||||
|
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
|
||||||
|
|
||||||
interface ReportPanelState {
|
interface ReportPanelState {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -43,10 +45,14 @@ export const openReportPanelAtom = atom(
|
||||||
wordCount: wordCount ?? null,
|
wordCount: wordCount ?? null,
|
||||||
shareToken: shareToken ?? null,
|
shareToken: shareToken ?? null,
|
||||||
});
|
});
|
||||||
|
set(rightPanelTabAtom, "report");
|
||||||
|
set(rightPanelCollapsedAtom, false);
|
||||||
|
set(documentsSidebarOpenAtom, true);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Action atom to close the report panel */
|
/** Action atom to close the report panel */
|
||||||
export const closeReportPanelAtom = atom(null, (_, set) => {
|
export const closeReportPanelAtom = atom(null, (_get, set) => {
|
||||||
set(reportPanelAtom, initialState);
|
set(reportPanelAtom, initialState);
|
||||||
|
set(rightPanelTabAtom, "sources");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
8
surfsense_web/atoms/layout/right-panel.atom.ts
Normal file
8
surfsense_web/atoms/layout/right-panel.atom.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
export type RightPanelTab = "sources" | "report";
|
||||||
|
|
||||||
|
export const rightPanelTabAtom = atom<RightPanelTab>("sources");
|
||||||
|
|
||||||
|
/** Whether the right panel is collapsed (hidden but state preserved) */
|
||||||
|
export const rightPanelCollapsedAtom = atom(false);
|
||||||
|
|
@ -6,16 +6,11 @@ import {
|
||||||
useAssistantState,
|
useAssistantState,
|
||||||
useMessage,
|
useMessage,
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
|
import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
addingCommentToMessageIdAtom,
|
|
||||||
commentsCollapsedAtom,
|
|
||||||
commentsEnabledAtom,
|
|
||||||
targetCommentIdAtom,
|
|
||||||
} from "@/atoms/chat/current-thread.atom";
|
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||||
import {
|
import {
|
||||||
|
|
@ -26,7 +21,6 @@ import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
|
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
|
||||||
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
|
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
|
||||||
import { CommentTrigger } from "@/components/chat-comments/comment-trigger/comment-trigger";
|
|
||||||
import { useComments } from "@/hooks/use-comments";
|
import { useComments } from "@/hooks/use-comments";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -96,20 +90,17 @@ function parseMessageId(assistantUiMessageId: string | undefined): number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AssistantMessage: FC = () => {
|
export const AssistantMessage: FC = () => {
|
||||||
const [messageHeight, setMessageHeight] = useState<number | undefined>(undefined);
|
|
||||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
|
const [isInlineOpen, setIsInlineOpen] = useState(false);
|
||||||
const messageRef = useRef<HTMLDivElement>(null);
|
const messageRef = useRef<HTMLDivElement>(null);
|
||||||
|
const commentPanelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const commentTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
const messageId = useAssistantState(({ message }) => message?.id);
|
const messageId = useAssistantState(({ message }) => message?.id);
|
||||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||||
const dbMessageId = parseMessageId(messageId);
|
const dbMessageId = parseMessageId(messageId);
|
||||||
const commentsEnabled = useAtomValue(commentsEnabledAtom);
|
const commentsEnabled = useAtomValue(commentsEnabledAtom);
|
||||||
const commentsCollapsed = useAtomValue(commentsCollapsedAtom);
|
|
||||||
const [addingCommentToMessageId, setAddingCommentToMessageId] = useAtom(
|
|
||||||
addingCommentToMessageIdAtom
|
|
||||||
);
|
|
||||||
|
|
||||||
// Screen size detection for responsive comment UI
|
// Desktop: >= 1024px (inline expandable), Medium: 768px-1023px (right sheet), Mobile: <768px (bottom sheet)
|
||||||
// Mobile: < 768px (bottom sheet), Medium: 768px - 1024px (right sheet), Desktop: >= 1024px (inline panel)
|
|
||||||
const isMediumScreen = useMediaQuery("(min-width: 768px) and (max-width: 1023px)");
|
const isMediumScreen = useMediaQuery("(min-width: 768px) and (max-width: 1023px)");
|
||||||
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||||
|
|
||||||
|
|
@ -122,10 +113,8 @@ export const AssistantMessage: FC = () => {
|
||||||
enabled: !!dbMessageId,
|
enabled: !!dbMessageId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Target comment navigation - read target from global atom
|
|
||||||
const targetCommentId = useAtomValue(targetCommentIdAtom);
|
const targetCommentId = useAtomValue(targetCommentIdAtom);
|
||||||
|
|
||||||
// Check if target comment belongs to this message (including replies)
|
|
||||||
const hasTargetComment = useMemo(() => {
|
const hasTargetComment = useMemo(() => {
|
||||||
if (!targetCommentId || !commentsData?.comments) return false;
|
if (!targetCommentId || !commentsData?.comments) return false;
|
||||||
return commentsData.comments.some(
|
return commentsData.comments.some(
|
||||||
|
|
@ -135,27 +124,36 @@ export const AssistantMessage: FC = () => {
|
||||||
|
|
||||||
const commentCount = commentsData?.total_count ?? 0;
|
const commentCount = commentsData?.total_count ?? 0;
|
||||||
const hasComments = commentCount > 0;
|
const hasComments = commentCount > 0;
|
||||||
const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId;
|
|
||||||
const showCommentPanel = hasComments || isAddingComment;
|
|
||||||
|
|
||||||
const handleToggleAddComment = () => {
|
const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
|
||||||
if (!dbMessageId) return;
|
|
||||||
setAddingCommentToMessageId(isAddingComment ? null : dbMessageId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCommentTriggerClick = () => {
|
|
||||||
setIsSheetOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Close floating panel when clicking outside (but not on portaled popover/dropdown content)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!messageRef.current) return;
|
if (!isInlineOpen) return;
|
||||||
const el = messageRef.current;
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
const update = () => setMessageHeight(el.offsetHeight);
|
const target = e.target as Element;
|
||||||
update();
|
if (
|
||||||
const observer = new ResizeObserver(update);
|
commentPanelRef.current?.contains(target) ||
|
||||||
observer.observe(el);
|
commentTriggerRef.current?.contains(target) ||
|
||||||
return () => observer.disconnect();
|
target.closest?.("[data-radix-popper-content-wrapper]")
|
||||||
}, []);
|
)
|
||||||
|
return;
|
||||||
|
setIsInlineOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [isInlineOpen]);
|
||||||
|
|
||||||
|
// Auto-open floating panel on desktop when this message has the target comment
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasTargetComment && isDesktop && commentsLoaded) {
|
||||||
|
setIsInlineOpen(true);
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
messageRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [hasTargetComment, isDesktop, commentsLoaded]);
|
||||||
|
|
||||||
// Auto-open sheet on mobile/tablet when this message has the target comment
|
// Auto-open sheet on mobile/tablet when this message has the target comment
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -164,20 +162,6 @@ export const AssistantMessage: FC = () => {
|
||||||
}
|
}
|
||||||
}, [hasTargetComment, isDesktop, commentsLoaded]);
|
}, [hasTargetComment, isDesktop, commentsLoaded]);
|
||||||
|
|
||||||
// Scroll message into view when it contains target comment (desktop)
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasTargetComment && isDesktop && commentsLoaded && messageRef.current) {
|
|
||||||
// Small delay to ensure DOM is ready after comments render
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
messageRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
}, 100);
|
|
||||||
return () => clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
}, [hasTargetComment, isDesktop, commentsLoaded]);
|
|
||||||
|
|
||||||
const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
|
|
||||||
|
|
||||||
// Determine sheet side based on screen size
|
|
||||||
const sheetSide = isMediumScreen ? "right" : "bottom";
|
const sheetSide = isMediumScreen ? "right" : "bottom";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -186,54 +170,25 @@ export const AssistantMessage: FC = () => {
|
||||||
className="aui-assistant-message-root group fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
className="aui-assistant-message-root group fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
||||||
data-role="assistant"
|
data-role="assistant"
|
||||||
>
|
>
|
||||||
<AssistantMessageInner />
|
{/* Comment trigger — right-aligned, just below user query on all screen sizes */}
|
||||||
|
{showCommentTrigger && (
|
||||||
{/* Desktop comment panel - only on lg screens and above, hidden when collapsed */}
|
<div className="mr-2 mb-1 flex justify-end">
|
||||||
{searchSpaceId && commentsEnabled && !isMessageStreaming && !commentsCollapsed && (
|
|
||||||
<div className="absolute left-full top-0 ml-4 hidden lg:block w-72">
|
|
||||||
<div
|
|
||||||
className={`sticky top-3 ${showCommentPanel ? "opacity-100" : "opacity-0 group-hover:opacity-100"} transition-opacity`}
|
|
||||||
>
|
|
||||||
{!hasComments && (
|
|
||||||
<CommentTrigger
|
|
||||||
commentCount={0}
|
|
||||||
isOpen={isAddingComment}
|
|
||||||
onClick={handleToggleAddComment}
|
|
||||||
disabled={!dbMessageId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showCommentPanel && dbMessageId && (
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
hasComments ? "" : "mt-2 animate-in fade-in slide-in-from-top-2 duration-200"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CommentPanelContainer
|
|
||||||
messageId={dbMessageId}
|
|
||||||
isOpen={true}
|
|
||||||
maxHeight={messageHeight}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile & Medium screen comment trigger - shown below lg breakpoint */}
|
|
||||||
{showCommentTrigger && !isDesktop && (
|
|
||||||
<div className="ml-2 mt-1 flex justify-start">
|
|
||||||
<button
|
<button
|
||||||
|
ref={isDesktop ? commentTriggerRef : undefined}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCommentTriggerClick}
|
onClick={
|
||||||
|
isDesktop ? () => setIsInlineOpen((prev) => !prev) : () => setIsSheetOpen(true)
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-full px-3 py-1.5 text-sm transition-colors",
|
"flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors",
|
||||||
hasComments
|
isDesktop && isInlineOpen
|
||||||
? "border border-primary/50 bg-primary/5 text-primary hover:bg-primary/10"
|
? "bg-primary/10 text-primary"
|
||||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
: hasComments
|
||||||
|
? "text-primary hover:bg-primary/10"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageSquare className={cn("size-4", hasComments && "fill-current")} />
|
<MessageSquare className={cn("size-3.5", hasComments && "fill-current")} />
|
||||||
{hasComments ? (
|
{hasComments ? (
|
||||||
<span>
|
<span>
|
||||||
{commentCount} {commentCount === 1 ? "comment" : "comments"}
|
{commentCount} {commentCount === 1 ? "comment" : "comments"}
|
||||||
|
|
@ -245,7 +200,19 @@ export const AssistantMessage: FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Comment sheet - bottom for mobile, right for medium screens */}
|
{/* Desktop floating comment panel — overlays on top of chat content */}
|
||||||
|
{showCommentTrigger && isDesktop && isInlineOpen && dbMessageId && (
|
||||||
|
<div
|
||||||
|
ref={commentPanelRef}
|
||||||
|
className="absolute right-0 top-10 z-30 w-full max-w-md animate-in fade-in slide-in-from-top-2 duration-200"
|
||||||
|
>
|
||||||
|
<CommentPanelContainer messageId={dbMessageId} isOpen={true} variant="inline" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AssistantMessageInner />
|
||||||
|
|
||||||
|
{/* Comment sheet — bottom for mobile, right for medium screens */}
|
||||||
{showCommentTrigger && !isDesktop && (
|
{showCommentTrigger && !isDesktop && (
|
||||||
<CommentSheet
|
<CommentSheet
|
||||||
messageId={dbMessageId}
|
messageId={dbMessageId}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { type FC, forwardRef, useImperativeHandle, useMemo } from "react";
|
import { type FC, forwardRef, useImperativeHandle, useMemo } from "react";
|
||||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||||
|
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||||
import {
|
import {
|
||||||
globalNewLLMConfigsAtom,
|
globalNewLLMConfigsAtom,
|
||||||
llmPreferencesAtom,
|
llmPreferencesAtom,
|
||||||
|
|
@ -19,7 +20,6 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
|
||||||
import { useConnectorsElectric } from "@/hooks/use-connectors-electric";
|
import { useConnectorsElectric } from "@/hooks/use-connectors-electric";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
|
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
|
||||||
|
|
@ -206,7 +206,9 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
||||||
{showTrigger && (
|
{showTrigger && (
|
||||||
<TooltipIconButton
|
<TooltipIconButton
|
||||||
data-joyride="connector-icon"
|
data-joyride="connector-icon"
|
||||||
tooltip={hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"}
|
tooltip={
|
||||||
|
hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"
|
||||||
|
}
|
||||||
side="bottom"
|
side="bottom"
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
|
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
|
||||||
|
|
@ -215,7 +217,9 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
||||||
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
|
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
|
||||||
)}
|
)}
|
||||||
aria-label={
|
aria-label={
|
||||||
hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector"
|
hasConnectors
|
||||||
|
? `View ${activeConnectorsCount} connectors`
|
||||||
|
: "Add your first connector"
|
||||||
}
|
}
|
||||||
onClick={() => handleOpenChange(true)}
|
onClick={() => handleOpenChange(true)}
|
||||||
>
|
>
|
||||||
|
|
@ -411,7 +415,9 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
||||||
indexingConnectorIds={indexingConnectorIds}
|
indexingConnectorIds={indexingConnectorIds}
|
||||||
onConnectOAuth={hasDocumentSummaryLLM ? handleConnectOAuth : () => {}}
|
onConnectOAuth={hasDocumentSummaryLLM ? handleConnectOAuth : () => {}}
|
||||||
onConnectNonOAuth={hasDocumentSummaryLLM ? handleConnectNonOAuth : () => {}}
|
onConnectNonOAuth={hasDocumentSummaryLLM ? handleConnectNonOAuth : () => {}}
|
||||||
onCreateWebcrawler={hasDocumentSummaryLLM ? handleCreateWebcrawler : () => {}}
|
onCreateWebcrawler={
|
||||||
|
hasDocumentSummaryLLM ? handleCreateWebcrawler : () => {}
|
||||||
|
}
|
||||||
onCreateYouTubeCrawler={
|
onCreateYouTubeCrawler={
|
||||||
hasDocumentSummaryLLM ? handleCreateYouTubeCrawler : () => {}
|
hasDocumentSummaryLLM ? handleCreateYouTubeCrawler : () => {}
|
||||||
}
|
}
|
||||||
|
|
@ -441,6 +447,7 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
ConnectorIndicator.displayName = "ConnectorIndicator";
|
ConnectorIndicator.displayName = "ConnectorIndicator";
|
||||||
|
|
|
||||||
|
|
@ -70,11 +70,10 @@ export const BaiduSearchApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSu
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-6">
|
<div className="space-y-6 pb-6">
|
||||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
<Info className="size-4 shrink-0" />
|
||||||
<div className="-ml-1">
|
|
||||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
<AlertDescription className="text-[10px] sm:text-xs">
|
||||||
You'll need a Baidu AppBuilder API key to use this connector. You can get one by signing
|
You'll need a Baidu AppBuilder API key to use this connector. You can get one by signing
|
||||||
up at{" "}
|
up at{" "}
|
||||||
<a
|
<a
|
||||||
|
|
@ -86,7 +85,6 @@ export const BaiduSearchApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSu
|
||||||
qianfan.cloud.baidu.com
|
qianfan.cloud.baidu.com
|
||||||
</a>
|
</a>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -96,15 +96,13 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-6">
|
<div className="space-y-6 pb-6">
|
||||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
<Info className="size-4 shrink-0" />
|
||||||
<div className="-ml-1">
|
|
||||||
<AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle>
|
<AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle>
|
||||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
<AlertDescription className="text-[10px] sm:text-xs">
|
||||||
You'll need a BookStack API Token to use this connector. You can create one from your
|
You'll need a BookStack API Token to use this connector. You can create one from your
|
||||||
BookStack instance settings.
|
BookStack instance settings.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -64,15 +64,13 @@ export const CirclebackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmit
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-6">
|
<div className="space-y-6 pb-6">
|
||||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||||
<Webhook className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
<Webhook className="size-4 shrink-0" />
|
||||||
<div className="-ml-1">
|
|
||||||
<AlertTitle className="text-xs sm:text-sm">Webhook-Based Integration</AlertTitle>
|
<AlertTitle className="text-xs sm:text-sm">Webhook-Based Integration</AlertTitle>
|
||||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
<AlertDescription className="text-[10px] sm:text-xs">
|
||||||
Circleback uses webhooks to automatically send meeting data. After connecting, you'll
|
Circleback uses webhooks to automatically send meeting data. After connecting, you'll
|
||||||
receive a webhook URL to configure in your Circleback settings.
|
receive a webhook URL to configure in your Circleback settings.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -172,14 +172,12 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-6">
|
<div className="space-y-6 pb-6">
|
||||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
<Info className="size-4 shrink-0" />
|
||||||
<div className="-ml-1">
|
|
||||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
<AlertDescription className="text-[10px] sm:text-xs">
|
||||||
Enter your Elasticsearch cluster endpoint URL and authentication credentials to connect.
|
Enter your Elasticsearch cluster endpoint URL and authentication credentials to connect.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -105,13 +105,11 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-6">
|
<div className="space-y-6 pb-6">
|
||||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
<Info className="size-4 shrink-0" />
|
||||||
<div className="-ml-1">
|
|
||||||
<AlertTitle className="text-xs sm:text-sm">Personal Access Token (Optional)</AlertTitle>
|
<AlertTitle className="text-xs sm:text-sm">Personal Access Token (Optional)</AlertTitle>
|
||||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
<AlertDescription className="text-[10px] sm:text-xs">
|
||||||
A GitHub PAT is only required for private repositories. Public repos work without a
|
A GitHub PAT is only required for private repositories. Public repos work without a token.{" "}
|
||||||
token.{" "}
|
|
||||||
<a
|
<a
|
||||||
href="https://github.com/settings/tokens/new?description=surfsense&scopes=repo"
|
href="https://github.com/settings/tokens/new?description=surfsense&scopes=repo"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
@ -122,7 +120,6 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
|
||||||
<ExternalLink className="h-3 w-3 sm:h-4 sm:w-4" />
|
<ExternalLink className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -70,11 +70,10 @@ export const LinkupApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-6">
|
<div className="space-y-6 pb-6">
|
||||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
<Info className="size-4 shrink-0" />
|
||||||
<div className="-ml-1">
|
|
||||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
<AlertDescription className="text-[10px] sm:text-xs">
|
||||||
You'll need a Linkup API key to use this connector. You can get one by signing up at{" "}
|
You'll need a Linkup API key to use this connector. You can get one by signing up at{" "}
|
||||||
<a
|
<a
|
||||||
href="https://linkup.so"
|
href="https://linkup.so"
|
||||||
|
|
@ -85,7 +84,6 @@ export const LinkupApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
|
||||||
linkup.so
|
linkup.so
|
||||||
</a>
|
</a>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -88,11 +88,10 @@ export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-6">
|
<div className="space-y-6 pb-6">
|
||||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
<Info className="size-4 shrink-0" />
|
||||||
<div className="-ml-1">
|
|
||||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
<AlertDescription className="text-[10px] sm:text-xs">
|
||||||
You'll need a Luma API Key to use this connector. You can create one from{" "}
|
You'll need a Luma API Key to use this connector. You can create one from{" "}
|
||||||
<a
|
<a
|
||||||
href="https://lu.ma/api"
|
href="https://lu.ma/api"
|
||||||
|
|
@ -103,7 +102,6 @@ export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
|
||||||
Luma API Settings
|
Luma API Settings
|
||||||
</a>
|
</a>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-6">
|
<div className="space-y-6 pb-6">
|
||||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 [&>svg]:top-2 sm:[&>svg]:top-3">
|
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||||
<Server className="h-4 w-4 shrink-0" />
|
<Server className="h-4 w-4 shrink-0" />
|
||||||
<AlertDescription className="text-[10px] sm:text-xs">
|
<AlertDescription className="text-[10px] sm:text-xs">
|
||||||
Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a separate
|
Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a separate
|
||||||
|
|
@ -230,12 +230,9 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="h-4 w-4 text-red-600" />
|
<XCircle className="h-4 w-4 text-red-600" />
|
||||||
)}
|
)}
|
||||||
<div className="flex-1">
|
<div className="col-start-2 flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<AlertTitle className="text-sm">
|
<AlertTitle className="text-sm">
|
||||||
{testResult.status === "success"
|
{testResult.status === "success" ? "Connection Successful" : "Connection Failed"}
|
||||||
? "Connection Successful"
|
|
||||||
: "Connection Failed"}
|
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
{testResult.tools.length > 0 && (
|
{testResult.tools.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -278,7 +275,6 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -102,15 +102,13 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitti
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-6">
|
<div className="space-y-6 pb-6">
|
||||||
<Alert className="bg-purple-500/10 dark:bg-purple-500/10 border-purple-500/30 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
<Alert className="bg-purple-500/10 dark:bg-purple-500/10 border-purple-500/30 p-2 sm:p-3">
|
||||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1 text-purple-500" />
|
<Info className="size-4 shrink-0 text-purple-500" />
|
||||||
<div className="-ml-1">
|
|
||||||
<AlertTitle className="text-xs sm:text-sm">Self-Hosted Only</AlertTitle>
|
<AlertTitle className="text-xs sm:text-sm">Self-Hosted Only</AlertTitle>
|
||||||
<AlertDescription className="text-[10px] sm:text-xs pl-0!">
|
<AlertDescription className="text-[10px] sm:text-xs">
|
||||||
This connector requires direct file system access and only works with self-hosted
|
This connector requires direct file system access and only works with self-hosted
|
||||||
SurfSense installations.
|
SurfSense installations.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -123,11 +123,10 @@ export const SearxngConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmittin
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-6">
|
<div className="space-y-6 pb-6">
|
||||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
<Info className="size-4 shrink-0" />
|
||||||
<div className="-ml-1">
|
|
||||||
<AlertTitle className="text-xs sm:text-sm">SearxNG Instance Required</AlertTitle>
|
<AlertTitle className="text-xs sm:text-sm">SearxNG Instance Required</AlertTitle>
|
||||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
<AlertDescription className="text-[10px] sm:text-xs">
|
||||||
You need access to a running SearxNG instance. Refer to the{" "}
|
You need access to a running SearxNG instance. Refer to the{" "}
|
||||||
<a
|
<a
|
||||||
href="https://docs.searxng.org/admin/installation-docker.html"
|
href="https://docs.searxng.org/admin/installation-docker.html"
|
||||||
|
|
@ -139,7 +138,6 @@ export const SearxngConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmittin
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
for setup instructions. If your instance requires an API key, include it below.
|
for setup instructions. If your instance requires an API key, include it below.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -70,11 +70,10 @@ export const TavilyApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-6">
|
<div className="space-y-6 pb-6">
|
||||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
<Info className="size-4 shrink-0" />
|
||||||
<div className="-ml-1">
|
|
||||||
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
|
||||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
<AlertDescription className="text-[10px] sm:text-xs">
|
||||||
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
|
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
|
||||||
<a
|
<a
|
||||||
href="https://tavily.com"
|
href="https://tavily.com"
|
||||||
|
|
@ -85,7 +84,6 @@ export const TavilyApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
|
||||||
tavily.com
|
tavily.com
|
||||||
</a>
|
</a>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameC
|
||||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
<AlertTitle className="text-xs sm:text-sm">Configuration Instructions</AlertTitle>
|
<AlertTitle className="text-xs sm:text-sm">Configuration Instructions</AlertTitle>
|
||||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0 mt-1">
|
<AlertDescription className="text-[10px] sm:text-xs mt-1">
|
||||||
Configure this URL in Circleback Settings → Automations → Create automation → Send
|
Configure this URL in Circleback Settings → Automations → Create automation → Send
|
||||||
webhook request. The webhook will automatically send meeting notes, transcripts, and
|
webhook request. The webhook will automatically send meeting notes, transcripts, and
|
||||||
action items to this search space.
|
action items to this search space.
|
||||||
|
|
|
||||||
|
|
@ -235,12 +235,9 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="h-4 w-4 text-red-600" />
|
<XCircle className="h-4 w-4 text-red-600" />
|
||||||
)}
|
)}
|
||||||
<div className="flex-1">
|
<div className="col-start-2 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0">
|
|
||||||
<AlertTitle className="text-sm">
|
<AlertTitle className="text-sm">
|
||||||
{testResult.status === "success"
|
{testResult.status === "success" ? "Connection Successful" : "Connection Failed"}
|
||||||
? "Connection Successful"
|
|
||||||
: "Connection Failed"}
|
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
{testResult.tools.length > 0 && (
|
{testResult.tools.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -283,7 +280,6 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,8 @@ export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfig
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-blue-200/50 bg-blue-50/50 dark:border-blue-500/20 dark:bg-blue-950/20 p-3 text-xs sm:text-sm">
|
<div className="flex items-start gap-3 rounded-lg border border-blue-200/50 bg-blue-50/50 dark:border-blue-500/20 dark:bg-blue-950/20 p-3 text-xs sm:text-sm">
|
||||||
<Info className="size-4 mt-0.5 shrink-0 text-blue-600 dark:text-blue-400" />
|
<Info className="size-4 mt-0.5 shrink-0 text-blue-600 dark:text-blue-400" />
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Want a quick answer from a webpage without indexing it? Just paste the URL directly into the chat instead.
|
Want a quick answer from a webpage without indexing it? Just paste the URL directly into
|
||||||
|
the chat instead.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -123,9 +124,9 @@ export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfig
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Alert */}
|
{/* Info Alert */}
|
||||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center gap-2 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0" />
|
<Info className="size-4 shrink-0" />
|
||||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
<AlertDescription className="text-[10px] sm:text-xs">
|
||||||
Configuration is saved when you start indexing. You can update these settings anytime from
|
Configuration is saved when you start indexing. You can update these settings anytime from
|
||||||
the connector management page.
|
the connector management page.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|
|
||||||
|
|
@ -280,9 +280,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
||||||
|
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-blue-200/50 bg-blue-50/50 dark:border-blue-500/20 dark:bg-blue-950/20 p-4 text-sm">
|
<div className="flex items-start gap-3 rounded-lg border border-blue-200/50 bg-blue-50/50 dark:border-blue-500/20 dark:bg-blue-950/20 p-4 text-sm">
|
||||||
<Info className="size-4 mt-0.5 shrink-0 text-blue-600 dark:text-blue-400" />
|
<Info className="size-4 mt-0.5 shrink-0 text-blue-600 dark:text-blue-400" />
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">{t("chat_tip")}</p>
|
||||||
{t("chat_tip")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-muted/50 rounded-lg p-4 text-sm">
|
<div className="bg-muted/50 rounded-lg p-4 text-sm">
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowDownIcon,
|
ArrowDownIcon,
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
Cable,
|
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
|
@ -23,17 +22,20 @@ import {
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
SquareIcon,
|
SquareIcon,
|
||||||
SquareLibrary,
|
Unplug,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||||
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
|
|
||||||
import {
|
import {
|
||||||
mentionedDocumentsAtom,
|
mentionedDocumentsAtom,
|
||||||
sidebarSelectedDocumentsAtom,
|
sidebarSelectedDocumentsAtom,
|
||||||
} from "@/atoms/chat/mentioned-documents.atom";
|
} from "@/atoms/chat/mentioned-documents.atom";
|
||||||
|
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||||
|
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import {
|
import {
|
||||||
|
|
@ -48,6 +50,7 @@ import {
|
||||||
ConnectorIndicator,
|
ConnectorIndicator,
|
||||||
type ConnectorIndicatorHandle,
|
type ConnectorIndicatorHandle,
|
||||||
} from "@/components/assistant-ui/connector-popup";
|
} from "@/components/assistant-ui/connector-popup";
|
||||||
|
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||||
import {
|
import {
|
||||||
InlineMentionEditor,
|
InlineMentionEditor,
|
||||||
type InlineMentionEditorRef,
|
type InlineMentionEditorRef,
|
||||||
|
|
@ -60,13 +63,21 @@ import {
|
||||||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
import { UserMessage } from "@/components/assistant-ui/user-message";
|
import { UserMessage } from "@/components/assistant-ui/user-message";
|
||||||
|
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/components/layout/ui/sidebar/SidebarSlideOutPanel";
|
||||||
import {
|
import {
|
||||||
DocumentMentionPicker,
|
DocumentMentionPicker,
|
||||||
type DocumentMentionPickerRef,
|
type DocumentMentionPickerRef,
|
||||||
} from "@/components/new-chat/document-mention-picker";
|
} from "@/components/new-chat/document-mention-picker";
|
||||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||||
|
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import type { Document } from "@/contracts/types/document.types";
|
import type { Document } from "@/contracts/types/document.types";
|
||||||
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
||||||
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
||||||
|
|
@ -95,8 +106,6 @@ export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map() }) =>
|
||||||
};
|
};
|
||||||
|
|
||||||
const ThreadContent: FC = () => {
|
const ThreadContent: FC = () => {
|
||||||
const showGutter = useAtomValue(showCommentsGutterAtom);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThreadPrimitive.Root
|
<ThreadPrimitive.Root
|
||||||
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-background"
|
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-background"
|
||||||
|
|
@ -106,10 +115,7 @@ const ThreadContent: FC = () => {
|
||||||
>
|
>
|
||||||
<ThreadPrimitive.Viewport
|
<ThreadPrimitive.Viewport
|
||||||
turnAnchor="top"
|
turnAnchor="top"
|
||||||
className={cn(
|
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4"
|
||||||
"aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 transition-[padding] duration-300 ease-out",
|
|
||||||
showGutter && "lg:pr-30"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
||||||
<ThreadWelcome />
|
<ThreadWelcome />
|
||||||
|
|
@ -228,6 +234,72 @@ const ThreadWelcome: FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const BANNER_CONNECTORS = [
|
||||||
|
{ type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" },
|
||||||
|
{ type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" },
|
||||||
|
{ type: "NOTION_CONNECTOR", label: "Notion" },
|
||||||
|
{ type: "YOUTUBE_CONNECTOR", label: "YouTube" },
|
||||||
|
{ type: "SLACK_CONNECTOR", label: "Slack" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const BANNER_DISMISSED_KEY = "surfsense-connect-tools-banner-dismissed";
|
||||||
|
|
||||||
|
const ConnectToolsBanner: FC = () => {
|
||||||
|
const { data: connectors } = useAtomValue(connectorsAtom);
|
||||||
|
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
||||||
|
const [dismissed, setDismissed] = useState(() => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return localStorage.getItem(BANNER_DISMISSED_KEY) === "true";
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasConnectors = (connectors?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
if (dismissed || hasConnectors) return null;
|
||||||
|
|
||||||
|
const handleDismiss = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDismissed(true);
|
||||||
|
localStorage.setItem(BANNER_DISMISSED_KEY, "true");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="md:hidden border-t border-border/50 bg-muted-foreground/[0.04]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-left transition-colors hover:bg-muted-foreground/[0.06] active:bg-muted-foreground/[0.1]"
|
||||||
|
onClick={() => setConnectorDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Unplug className="size-4 text-muted-foreground/70 shrink-0" />
|
||||||
|
<span className="text-[13px] text-muted-foreground/80 flex-1">Connect your tools</span>
|
||||||
|
<AvatarGroup className="shrink-0">
|
||||||
|
{BANNER_CONNECTORS.map(({ type, label }, i) => (
|
||||||
|
<Avatar key={type} className="size-6" style={{ zIndex: BANNER_CONNECTORS.length - i }}>
|
||||||
|
<AvatarFallback className="bg-muted text-[10px]">
|
||||||
|
{getConnectorIcon(type, "size-3.5")}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
))}
|
||||||
|
</AvatarGroup>
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={handleDismiss}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDismiss(e as unknown as React.MouseEvent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="shrink-0 ml-0.5 p-0.5 text-muted-foreground/40 hover:text-foreground transition-colors"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const Composer: FC = () => {
|
const Composer: FC = () => {
|
||||||
// Document mention state (atoms persist across component remounts)
|
// Document mention state (atoms persist across component remounts)
|
||||||
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
||||||
|
|
@ -312,6 +384,16 @@ const Composer: FC = () => {
|
||||||
}
|
}
|
||||||
}, [isThreadEmpty]);
|
}, [isThreadEmpty]);
|
||||||
|
|
||||||
|
// Close document picker when a slide-out panel (inbox, shared/private chats) opens
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
setShowDocumentPopover(false);
|
||||||
|
setMentionQuery("");
|
||||||
|
};
|
||||||
|
window.addEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler);
|
||||||
|
return () => window.removeEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Sync editor text with assistant-ui composer runtime
|
// Sync editor text with assistant-ui composer runtime
|
||||||
const handleEditorChange = useCallback(
|
const handleEditorChange = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
|
|
@ -425,9 +507,9 @@ const Composer: FC = () => {
|
||||||
currentUserId={currentUser?.id ?? null}
|
currentUserId={currentUser?.id ?? null}
|
||||||
members={members ?? []}
|
members={members ?? []}
|
||||||
/>
|
/>
|
||||||
<div className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow">
|
<div className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow">
|
||||||
{/* Inline editor with @mention support */}
|
{/* Inline editor with @mention support */}
|
||||||
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6">
|
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-4 pt-3 pb-6">
|
||||||
<InlineMentionEditor
|
<InlineMentionEditor
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
placeholder={currentPlaceholder}
|
placeholder={currentPlaceholder}
|
||||||
|
|
@ -466,6 +548,7 @@ const Composer: FC = () => {
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
||||||
|
<ConnectToolsBanner />
|
||||||
</div>
|
</div>
|
||||||
</ComposerPrimitive.Root>
|
</ComposerPrimitive.Root>
|
||||||
);
|
);
|
||||||
|
|
@ -481,7 +564,9 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom);
|
const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom);
|
||||||
const connectorRef = useRef<ConnectorIndicatorHandle>(null);
|
const connectorRef = useRef<ConnectorIndicatorHandle>(null);
|
||||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||||
|
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
||||||
|
const { data: connectors } = useAtomValue(connectorsAtom);
|
||||||
|
const connectorCount = connectors?.length ?? 0;
|
||||||
const isComposerTextEmpty = useAssistantState(({ composer }) => {
|
const isComposerTextEmpty = useAssistantState(({ composer }) => {
|
||||||
const text = composer.text?.trim() || "";
|
const text = composer.text?.trim() || "";
|
||||||
return text.length === 0;
|
return text.length === 0;
|
||||||
|
|
@ -506,55 +591,61 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
|
const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
<div className="aui-composer-action-wrapper relative mx-3 mb-2 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Popover open={addMenuOpen} onOpenChange={setAddMenuOpen}>
|
<DropdownMenu open={addMenuOpen} onOpenChange={setAddMenuOpen}>
|
||||||
<PopoverTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<TooltipIconButton
|
<TooltipIconButton
|
||||||
tooltip="Configuration"
|
tooltip="Add files and more"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
||||||
aria-label="Configuration"
|
aria-label="Add files and more"
|
||||||
data-joyride="connector-icon"
|
data-joyride="connector-icon"
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
</PopoverTrigger>
|
</DropdownMenuTrigger>
|
||||||
<PopoverContent
|
<DropdownMenuContent
|
||||||
side="bottom"
|
side="bottom"
|
||||||
align="start"
|
align="start"
|
||||||
sideOffset={12}
|
sideOffset={12}
|
||||||
className="w-[calc(100vw-2rem)] max-w-60 sm:w-60 p-2"
|
className="w-[calc(100vw-2rem)] max-w-60 sm:w-60"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-0.5">
|
<DropdownMenuItem
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAddMenuOpen(false);
|
setAddMenuOpen(false);
|
||||||
setDocumentsSidebarOpen(true);
|
openUploadDialog();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SquareLibrary className="size-4 shrink-0" />
|
<Upload className="size-4 shrink-0" />
|
||||||
Documents
|
Upload files
|
||||||
</button>
|
</DropdownMenuItem>
|
||||||
<button
|
<DropdownMenuItem
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAddMenuOpen(false);
|
setAddMenuOpen(false);
|
||||||
connectorRef.current?.open();
|
connectorRef.current?.open();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Cable className="size-4 shrink-0" />
|
<Unplug className="size-4 shrink-0" />
|
||||||
Manage connectors
|
{connectorCount > 0 ? "Manage tools" : "Connect your tools"}
|
||||||
</button>
|
{connectorCount > 0 && (
|
||||||
</div>
|
<span className="ml-auto text-xs text-muted-foreground">{connectorCount}</span>
|
||||||
</PopoverContent>
|
)}
|
||||||
</Popover>
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
<ConnectorIndicator ref={connectorRef} showTrigger={false} />
|
<ConnectorIndicator ref={connectorRef} showTrigger={false} />
|
||||||
|
{sidebarDocs.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDocumentsSidebarOpen(true)}
|
||||||
|
className="rounded-full border border-border/60 bg-accent/50 px-2.5 py-1 text-xs font-medium text-foreground/80 transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
{sidebarDocs.length} {sidebarDocs.length === 1 ? "source" : "sources"} selected
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasModelConfigured && (
|
{!hasModelConfigured && (
|
||||||
|
|
@ -565,16 +656,6 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{sidebarDocs.length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDocumentsSidebarOpen(true)}
|
|
||||||
className="rounded-full border border-border/60 bg-accent/50 px-2.5 py-1 text-xs font-medium text-foreground/80 transition-colors hover:bg-accent"
|
|
||||||
>
|
|
||||||
{sidebarDocs.length} {sidebarDocs.length === 1 ? "source" : "sources"} selected
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
<AssistantIf condition={({ thread }) => !thread.isRunning}>
|
||||||
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
||||||
<TooltipIconButton
|
<TooltipIconButton
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Send, X } from "lucide-react";
|
import { ArrowUp, Send, X } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
|
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
|
||||||
|
|
@ -86,6 +86,7 @@ export function CommentComposer({
|
||||||
onCancel,
|
onCancel,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
initialValue = "",
|
initialValue = "",
|
||||||
|
compact = false,
|
||||||
}: CommentComposerProps) {
|
}: CommentComposerProps) {
|
||||||
const [displayContent, setDisplayContent] = useState(initialValue);
|
const [displayContent, setDisplayContent] = useState(initialValue);
|
||||||
const [insertedMentions, setInsertedMentions] = useState<InsertedMention[]>([]);
|
const [insertedMentions, setInsertedMentions] = useState<InsertedMention[]>([]);
|
||||||
|
|
@ -257,7 +258,8 @@ export function CommentComposer({
|
||||||
}, [adjustTextareaHeight]);
|
}, [adjustTextareaHeight]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className={cn("flex", compact ? "flex-row items-center gap-2" : "flex-col gap-2")}>
|
||||||
|
<div className={cn(compact && "flex-1 min-w-0")}>
|
||||||
<Popover
|
<Popover
|
||||||
open={mentionState.isActive}
|
open={mentionState.isActive}
|
||||||
onOpenChange={(open) => !open && closeMentionPicker()}
|
onOpenChange={(open) => !open && closeMentionPicker()}
|
||||||
|
|
@ -270,7 +272,7 @@ export function CommentComposer({
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="min-h-[40px] max-h-[200px] resize-none overflow-y-auto scrollbar-thin"
|
className="min-h-[40px] max-h-[200px] w-full resize-none overflow-y-auto scrollbar-thin border-none shadow-none focus-visible:ring-0 bg-transparent dark:bg-transparent"
|
||||||
rows={1}
|
rows={1}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
|
|
@ -293,8 +295,9 @@ export function CommentComposer({
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className={cn("flex items-center gap-2", !compact && "justify-end")}>
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -309,13 +312,19 @@ export function CommentComposer({
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size={compact ? "icon" : "sm"}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
className={cn(!canSubmit && "opacity-50")}
|
className={cn(!canSubmit && "opacity-50", compact && "size-8 shrink-0 rounded-full")}
|
||||||
>
|
>
|
||||||
|
{compact ? (
|
||||||
|
<ArrowUp className="size-4" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Send className="mr-1 size-4" />
|
<Send className="mr-1 size-4" />
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ export interface CommentComposerProps {
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
initialValue?: string;
|
initialValue?: string;
|
||||||
|
/** Compact mode: inline send button with ArrowUp icon, no label */
|
||||||
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MentionState {
|
export interface MentionState {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import type { CommentActionsProps } from "./types";
|
import type { CommentActionsProps } from "./types";
|
||||||
|
|
@ -34,7 +33,6 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
|
||||||
Edit
|
Edit
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{canEdit && canDelete && <DropdownMenuSeparator />}
|
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<DropdownMenuItem onClick={onDelete}>
|
<DropdownMenuItem onClick={onDelete}>
|
||||||
<Trash2 className="mr-2 size-4" />
|
<Trash2 className="mr-2 size-4" />
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,6 @@ export interface CommentPanelContainerProps {
|
||||||
messageId: number;
|
messageId: number;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
maxHeight?: number;
|
maxHeight?: number;
|
||||||
/** Variant for responsive styling - desktop shows border/bg, mobile is plain */
|
/** Variant for responsive styling - desktop shows border/bg, mobile is plain, inline fits within message width */
|
||||||
variant?: "desktop" | "mobile";
|
variant?: "desktop" | "mobile" | "inline";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { CommentComposer } from "../comment-composer/comment-composer";
|
import { CommentComposer } from "../comment-composer/comment-composer";
|
||||||
import { CommentThread } from "../comment-thread/comment-thread";
|
import { CommentThread } from "../comment-thread/comment-thread";
|
||||||
import type { CommentPanelProps } from "./types";
|
import type { CommentPanelProps } from "./types";
|
||||||
|
|
||||||
function getInitials(name: string | null | undefined, email: string): string {
|
|
||||||
if (name) {
|
|
||||||
return name
|
|
||||||
.split(" ")
|
|
||||||
.map((part) => part[0])
|
|
||||||
.join("")
|
|
||||||
.toUpperCase()
|
|
||||||
.slice(0, 2);
|
|
||||||
}
|
|
||||||
return email[0].toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommentPanel({
|
export function CommentPanel({
|
||||||
threads,
|
threads,
|
||||||
members,
|
members,
|
||||||
|
|
@ -33,20 +18,23 @@ export function CommentPanel({
|
||||||
maxHeight,
|
maxHeight,
|
||||||
variant = "desktop",
|
variant = "desktop",
|
||||||
}: CommentPanelProps) {
|
}: CommentPanelProps) {
|
||||||
const [{ data: currentUser }] = useAtom(currentUserAtom);
|
|
||||||
|
|
||||||
const handleCommentSubmit = (content: string) => {
|
const handleCommentSubmit = (content: string) => {
|
||||||
onCreateComment(content);
|
onCreateComment(content);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMobile = variant === "mobile";
|
const isMobile = variant === "mobile";
|
||||||
|
const isInline = variant === "inline";
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[120px] items-center justify-center p-4",
|
"flex min-h-[120px] items-center justify-center p-4",
|
||||||
!isMobile && "w-96 rounded-lg border bg-card"
|
isInline &&
|
||||||
|
"w-full rounded-xl border-sidebar-border border bg-sidebar text-sidebar-foreground shadow-lg",
|
||||||
|
!isMobile &&
|
||||||
|
!isInline &&
|
||||||
|
"w-96 rounded-lg border-sidebar-border border bg-sidebar text-sidebar-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
|
@ -65,8 +53,18 @@ export function CommentPanel({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("flex flex-col", isMobile ? "w-full" : "w-85 rounded-lg border bg-card")}
|
className={cn(
|
||||||
style={!isMobile && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined}
|
"flex flex-col",
|
||||||
|
isMobile && "w-full",
|
||||||
|
isInline &&
|
||||||
|
"w-full rounded-xl border-sidebar-border border bg-sidebar text-sidebar-foreground shadow-lg max-h-80",
|
||||||
|
!isMobile &&
|
||||||
|
!isInline &&
|
||||||
|
"w-85 rounded-lg border-sidebar-border border bg-sidebar text-sidebar-foreground"
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
!isMobile && !isInline && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{hasThreads && (
|
{hasThreads && (
|
||||||
<div className={cn("min-h-0 flex-1 overflow-y-auto scrollbar-thin", isMobile && "pb-24")}>
|
<div className={cn("min-h-0 flex-1 overflow-y-auto scrollbar-thin", isMobile && "pb-24")}>
|
||||||
|
|
@ -87,25 +85,6 @@ export function CommentPanel({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!hasThreads && currentUser && (
|
|
||||||
<div className="flex items-center gap-3 px-4 pt-4 pb-1">
|
|
||||||
<Avatar className="size-10">
|
|
||||||
<AvatarImage
|
|
||||||
src={currentUser.avatar_url ?? undefined}
|
|
||||||
alt={currentUser.display_name ?? currentUser.email}
|
|
||||||
/>
|
|
||||||
<AvatarFallback className="bg-primary/10 text-primary text-sm font-medium">
|
|
||||||
{getInitials(currentUser.display_name, currentUser.email)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{currentUser.display_name ?? currentUser.email}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={cn("p-3", isMobile && "fixed bottom-0 left-0 right-0 z-50 bg-card border-t")}>
|
<div className={cn("p-3", isMobile && "fixed bottom-0 left-0 right-0 z-50 bg-card border-t")}>
|
||||||
<CommentComposer
|
<CommentComposer
|
||||||
members={members}
|
members={members}
|
||||||
|
|
@ -115,6 +94,7 @@ export function CommentPanel({
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onSubmit={handleCommentSubmit}
|
onSubmit={handleCommentSubmit}
|
||||||
autoFocus={!hasThreads}
|
autoFocus={!hasThreads}
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,6 @@ export interface CommentPanelProps {
|
||||||
onDeleteComment: (commentId: number) => void;
|
onDeleteComment: (commentId: number) => void;
|
||||||
isSubmitting?: boolean;
|
isSubmitting?: boolean;
|
||||||
maxHeight?: number;
|
maxHeight?: number;
|
||||||
/** Variant for responsive styling - desktop shows border/bg, mobile is plain */
|
/** Variant for responsive styling - desktop shows border/bg, mobile is plain, inline fits within message width */
|
||||||
variant?: "desktop" | "mobile";
|
variant?: "desktop" | "mobile" | "inline";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ export function CommentThread({
|
||||||
onSubmit={handleReplySubmit}
|
onSubmit={handleReplySubmit}
|
||||||
onCancel={handleReplyCancel}
|
onCancel={handleReplyCancel}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { MessageSquarePlus } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { CommentTriggerProps } from "./types";
|
|
||||||
|
|
||||||
export function CommentTrigger({ commentCount, isOpen, onClick, disabled }: CommentTriggerProps) {
|
|
||||||
const hasComments = commentCount > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant={hasComments ? "outline" : isOpen ? "secondary" : "ghost"}
|
|
||||||
size="icon"
|
|
||||||
disabled={disabled}
|
|
||||||
className={cn(
|
|
||||||
"relative size-10 rounded-full transition-all duration-200",
|
|
||||||
hasComments
|
|
||||||
? "border-primary/50 bg-primary/5 text-primary hover:bg-primary/10 hover:border-primary"
|
|
||||||
: isOpen
|
|
||||||
? "text-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground",
|
|
||||||
!hasComments && !isOpen && "opacity-0 group-hover:opacity-100",
|
|
||||||
disabled && "cursor-not-allowed opacity-50"
|
|
||||||
)}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<MessageSquarePlus className={cn("size-5", (hasComments || isOpen) && "fill-current")} />
|
|
||||||
{hasComments && (
|
|
||||||
<span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
|
|
||||||
{commentCount > 9 ? "9+" : commentCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export interface CommentTriggerProps {
|
|
||||||
commentCount: number;
|
|
||||||
isOpen: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
@ -29,7 +29,7 @@ export function MemberMentionItem({
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-3 px-3 py-2 text-left transition-colors",
|
"flex w-full items-center gap-3 px-3 py-2 text-left transition-colors",
|
||||||
isHighlighted ? "bg-accent" : "hover:bg-accent/50"
|
isHighlighted ? "bg-primary/15 text-accent-foreground" : "hover:bg-accent/50"
|
||||||
)}
|
)}
|
||||||
onClick={() => onSelect(member)}
|
onClick={() => onSelect(member)}
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { IconBrandGithub } from "@tabler/icons-react";
|
import { IconBrandGithub } from "@tabler/icons-react";
|
||||||
|
import { StarIcon } from "lucide-react";
|
||||||
import type { HTMLMotionProps, UseInViewOptions } from "motion/react";
|
import type { HTMLMotionProps, UseInViewOptions } from "motion/react";
|
||||||
import { motion, useInView, useMotionValue, useSpring } from "motion/react";
|
import {
|
||||||
|
AnimatePresence,
|
||||||
|
motion,
|
||||||
|
useInView,
|
||||||
|
useMotionValue,
|
||||||
|
useSpring,
|
||||||
|
useTransform,
|
||||||
|
} from "motion/react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -45,6 +53,122 @@ function useIsInView<T extends HTMLElement = HTMLElement>(
|
||||||
return { ref: localRef, isInView };
|
return { ref: localRef, isInView };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Particles (for star burst effect on completion)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
type ParticlesContextType = { animate: boolean; isInView: boolean };
|
||||||
|
const [ParticlesProvider, useParticles] =
|
||||||
|
getStrictContext<ParticlesContextType>("ParticlesContext");
|
||||||
|
|
||||||
|
function Particles({
|
||||||
|
ref,
|
||||||
|
animate = true,
|
||||||
|
inView = false,
|
||||||
|
inViewMargin = "0px",
|
||||||
|
inViewOnce = true,
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: Omit<HTMLMotionProps<"div">, "children"> & {
|
||||||
|
animate?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
} & UseIsInViewOptions) {
|
||||||
|
const { ref: localRef, isInView } = useIsInView(ref as React.Ref<HTMLDivElement>, {
|
||||||
|
inView,
|
||||||
|
inViewOnce,
|
||||||
|
inViewMargin,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<ParticlesProvider value={{ animate, isInView }}>
|
||||||
|
<motion.div ref={localRef} style={{ position: "relative", ...style }} {...props}>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</ParticlesProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ParticlesEffect({
|
||||||
|
side = "top",
|
||||||
|
align = "center",
|
||||||
|
count = 6,
|
||||||
|
radius = 30,
|
||||||
|
spread = 360,
|
||||||
|
duration = 0.8,
|
||||||
|
holdDelay = 0.05,
|
||||||
|
sideOffset = 0,
|
||||||
|
alignOffset = 0,
|
||||||
|
delay = 0,
|
||||||
|
transition,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: Omit<HTMLMotionProps<"div">, "children"> & {
|
||||||
|
side?: "top" | "bottom" | "left" | "right";
|
||||||
|
align?: "start" | "center" | "end";
|
||||||
|
count?: number;
|
||||||
|
radius?: number;
|
||||||
|
spread?: number;
|
||||||
|
duration?: number;
|
||||||
|
holdDelay?: number;
|
||||||
|
sideOffset?: number;
|
||||||
|
alignOffset?: number;
|
||||||
|
delay?: number;
|
||||||
|
}) {
|
||||||
|
const { animate, isInView } = useParticles();
|
||||||
|
const isVertical = side === "top" || side === "bottom";
|
||||||
|
const alignPct = align === "start" ? "0%" : align === "end" ? "100%" : "50%";
|
||||||
|
|
||||||
|
const top = isVertical
|
||||||
|
? side === "top"
|
||||||
|
? `calc(0% - ${sideOffset}px)`
|
||||||
|
: `calc(100% + ${sideOffset}px)`
|
||||||
|
: `calc(${alignPct} + ${alignOffset}px)`;
|
||||||
|
const left = isVertical
|
||||||
|
? `calc(${alignPct} + ${alignOffset}px)`
|
||||||
|
: side === "left"
|
||||||
|
? `calc(0% - ${sideOffset}px)`
|
||||||
|
: `calc(100% + ${sideOffset}px)`;
|
||||||
|
|
||||||
|
const containerStyle: React.CSSProperties = {
|
||||||
|
position: "absolute",
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
};
|
||||||
|
const angleStep = (spread * (Math.PI / 180)) / Math.max(1, count - 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{animate &&
|
||||||
|
isInView &&
|
||||||
|
[...Array(count)].map((_, i) => {
|
||||||
|
const angle = i * angleStep;
|
||||||
|
const x = Math.cos(angle) * radius;
|
||||||
|
const y = Math.sin(angle) * radius;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={`particle-${angle}`}
|
||||||
|
style={{ ...containerStyle, ...style }}
|
||||||
|
initial={{ scale: 0, opacity: 0 }}
|
||||||
|
animate={{
|
||||||
|
x: `${x}px`,
|
||||||
|
y: `${y}px`,
|
||||||
|
scale: [0, 1, 0],
|
||||||
|
opacity: [0, 1, 0],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration,
|
||||||
|
delay: delay + i * holdDelay,
|
||||||
|
ease: "easeOut",
|
||||||
|
...transition,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Per-digit scrolling wheel
|
// Per-digit scrolling wheel
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -193,42 +317,18 @@ function AnimatedStarCount({
|
||||||
value,
|
value,
|
||||||
itemSize = 22,
|
itemSize = 22,
|
||||||
isRolling = false,
|
isRolling = false,
|
||||||
animated = true,
|
|
||||||
className,
|
className,
|
||||||
onComplete,
|
onComplete,
|
||||||
}: {
|
}: {
|
||||||
value: number;
|
value: number;
|
||||||
itemSize?: number;
|
itemSize?: number;
|
||||||
isRolling?: boolean;
|
isRolling?: boolean;
|
||||||
animated?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const formatted = numberFormatter.format(value);
|
const formatted = numberFormatter.format(value);
|
||||||
const chars = formatted.split("");
|
const chars = formatted.split("");
|
||||||
|
|
||||||
if (!animated) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center">
|
|
||||||
{chars.map((char, idx) => (
|
|
||||||
<div
|
|
||||||
key={`static-${idx}-${char}`}
|
|
||||||
className={className}
|
|
||||||
style={{
|
|
||||||
height: itemSize,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
width: char >= "0" && char <= "9" ? undefined : "0.3em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{char}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalDigits = 0;
|
let totalDigits = 0;
|
||||||
for (const c of chars) {
|
for (const c of chars) {
|
||||||
if (c >= "0" && c <= "9") totalDigits++;
|
if (c >= "0" && c <= "9") totalDigits++;
|
||||||
|
|
@ -307,13 +407,13 @@ function NavbarGitHubStars({
|
||||||
href = "https://github.com/MODSetter/SurfSense",
|
href = "https://github.com/MODSetter/SurfSense",
|
||||||
className,
|
className,
|
||||||
}: NavbarGitHubStarsProps) {
|
}: NavbarGitHubStarsProps) {
|
||||||
const [hasMounted, setHasMounted] = React.useState(false);
|
|
||||||
const [stars, setStars] = React.useState(0);
|
const [stars, setStars] = React.useState(0);
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
const [isCompleted, setIsCompleted] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const fillRaw = useMotionValue(0);
|
||||||
setHasMounted(true);
|
const fillSpring = useSpring(fillRaw, { stiffness: 12, damping: 14 });
|
||||||
}, []);
|
const clipPath = useTransform(fillSpring, (v) => `inset(${100 - v * 100}% 0 0 0)`);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
|
@ -324,6 +424,7 @@ function NavbarGitHubStars({
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data && typeof data.stargazers_count === "number") {
|
if (data && typeof data.stargazers_count === "number") {
|
||||||
setStars(data.stargazers_count);
|
setStars(data.stargazers_count);
|
||||||
|
fillRaw.set(1);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|
@ -333,7 +434,7 @@ function NavbarGitHubStars({
|
||||||
})
|
})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
return () => abortController.abort();
|
return () => abortController.abort();
|
||||||
}, [username, repo]);
|
}, [username, repo, fillRaw]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
|
|
@ -341,20 +442,37 @@ function NavbarGitHubStars({
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group inline-flex items-center rounded-full border border-neutral-200 bg-white/80 px-3 py-1.5 text-sm backdrop-blur-sm transition-colors dark:border-neutral-800 dark:bg-neutral-950/80",
|
"group flex items-center gap-2 rounded-full px-3 py-1.5 transition-colors",
|
||||||
"hover:bg-neutral-100 dark:hover:bg-neutral-900",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<IconBrandGithub className="h-5 w-5 shrink-0 text-neutral-600 transition-colors dark:text-neutral-300 group-hover:text-neutral-800 dark:group-hover:text-neutral-100" />
|
<IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300 shrink-0" />
|
||||||
<div className="ml-2 flex items-center text-neutral-500 transition-colors dark:text-neutral-400 group-hover:text-neutral-800 dark:group-hover:text-neutral-200">
|
<div className="flex items-center gap-1 rounded-md bg-neutral-100 dark:bg-neutral-800 group-hover:bg-neutral-200 dark:group-hover:bg-neutral-700 px-2 py-0.5 transition-colors">
|
||||||
<AnimatedStarCount
|
<AnimatedStarCount
|
||||||
value={isLoading ? 10000 : stars}
|
value={isLoading ? 10000 : stars}
|
||||||
itemSize={ITEM_SIZE}
|
itemSize={ITEM_SIZE}
|
||||||
isRolling={hasMounted && isLoading}
|
isRolling={isLoading}
|
||||||
animated={hasMounted}
|
|
||||||
className="text-sm font-semibold tabular-nums text-neutral-500 dark:text-neutral-400 group-hover:text-neutral-800 dark:group-hover:text-neutral-200 transition-colors"
|
className="text-sm font-semibold tabular-nums text-neutral-500 dark:text-neutral-400 group-hover:text-neutral-800 dark:group-hover:text-neutral-200 transition-colors"
|
||||||
|
onComplete={() => setIsCompleted(true)}
|
||||||
/>
|
/>
|
||||||
|
<Particles animate={isCompleted}>
|
||||||
|
<div className="relative size-4">
|
||||||
|
<StarIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 size-4 fill-neutral-400 stroke-neutral-400 dark:fill-neutral-700 dark:stroke-neutral-700 group-hover:fill-neutral-600 group-hover:stroke-neutral-600 dark:group-hover:fill-neutral-300 dark:group-hover:stroke-neutral-300 transition-colors"
|
||||||
|
/>
|
||||||
|
<motion.div className="absolute inset-0" style={{ clipPath }}>
|
||||||
|
<StarIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-4 fill-neutral-300 stroke-neutral-300 dark:fill-neutral-400 dark:stroke-neutral-400 group-hover:fill-neutral-500 group-hover:stroke-neutral-500 dark:group-hover:fill-neutral-200 dark:group-hover:stroke-neutral-200 transition-colors"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
<ParticlesEffect
|
||||||
|
delay={0.3}
|
||||||
|
className="size-1 rounded-full bg-neutral-300 dark:bg-neutral-400"
|
||||||
|
/>
|
||||||
|
</Particles>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -176,10 +176,9 @@ function GetStartedButton() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const BackgroundGrids = () => {
|
const BackgroundGrids = () => {
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none absolute inset-0 z-0 grid h-full w-full -rotate-45 transform select-none grid-cols-2 gap-10 md:grid-cols-4">
|
<div className="pointer-events-none absolute inset-0 z-0 grid h-screen w-full -rotate-45 transform select-none grid-cols-2 gap-10 md:grid-cols-4">
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
<GridLineVertical className="left-0" />
|
<GridLineVertical className="left-0" />
|
||||||
<GridLineVertical className="left-auto right-0" />
|
<GridLineVertical className="left-auto right-0" />
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { toast } from "sonner";
|
||||||
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||||
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||||
|
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
||||||
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||||
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
|
|
@ -38,6 +39,7 @@ import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
|
||||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||||
import { useDocumentsProcessing } from "@/hooks/use-documents-processing";
|
import { useDocumentsProcessing } from "@/hooks/use-documents-processing";
|
||||||
import { useInbox } from "@/hooks/use-inbox";
|
import { useInbox } from "@/hooks/use-inbox";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
||||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
import { logout } from "@/lib/auth-utils";
|
import { logout } from "@/lib/auth-utils";
|
||||||
|
|
@ -74,6 +76,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
// Announcements
|
// Announcements
|
||||||
const { unreadCount: announcementUnreadCount } = useAnnouncements();
|
const { unreadCount: announcementUnreadCount } = useAnnouncements();
|
||||||
|
|
@ -117,10 +120,23 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
|
|
||||||
// Inbox sidebar state
|
// Inbox sidebar state
|
||||||
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
|
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
|
||||||
const [isInboxDocked, setIsInboxDocked] = useState(false);
|
|
||||||
|
|
||||||
// Documents sidebar state (shared atom so Composer can toggle it)
|
// Documents sidebar state (shared atom so Composer can toggle it)
|
||||||
const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom);
|
const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom);
|
||||||
|
const [isDocumentsDocked, setIsDocumentsDocked] = useState(true);
|
||||||
|
const [isRightPanelCollapsed, setIsRightPanelCollapsed] = useAtom(rightPanelCollapsedAtom);
|
||||||
|
|
||||||
|
// Open documents sidebar by default on desktop (docked mode)
|
||||||
|
const documentsInitialized = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!documentsInitialized.current) {
|
||||||
|
documentsInitialized.current = true;
|
||||||
|
const isDesktop = typeof window !== "undefined" && window.innerWidth >= 768;
|
||||||
|
if (isDesktop) {
|
||||||
|
setIsDocumentsSidebarOpen(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [setIsDocumentsSidebarOpen]);
|
||||||
|
|
||||||
// Announcements sidebar state
|
// Announcements sidebar state
|
||||||
const [isAnnouncementsSidebarOpen, setIsAnnouncementsSidebarOpen] = useState(false);
|
const [isAnnouncementsSidebarOpen, setIsAnnouncementsSidebarOpen] = useState(false);
|
||||||
|
|
@ -304,7 +320,9 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
title: "Documents",
|
title: "Documents",
|
||||||
url: "#documents",
|
url: "#documents",
|
||||||
icon: SquareLibrary,
|
icon: SquareLibrary,
|
||||||
isActive: isDocumentsSidebarOpen,
|
isActive: isMobile
|
||||||
|
? isDocumentsSidebarOpen
|
||||||
|
: isDocumentsSidebarOpen && !isRightPanelCollapsed,
|
||||||
statusIndicator: documentsProcessingStatus,
|
statusIndicator: documentsProcessingStatus,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -316,8 +334,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
isMobile,
|
||||||
isInboxSidebarOpen,
|
isInboxSidebarOpen,
|
||||||
isDocumentsSidebarOpen,
|
isDocumentsSidebarOpen,
|
||||||
|
isRightPanelCollapsed,
|
||||||
totalUnreadCount,
|
totalUnreadCount,
|
||||||
isAnnouncementsSidebarOpen,
|
isAnnouncementsSidebarOpen,
|
||||||
announcementUnreadCount,
|
announcementUnreadCount,
|
||||||
|
|
@ -419,7 +439,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
setIsAllSharedChatsSidebarOpen(false);
|
setIsAllSharedChatsSidebarOpen(false);
|
||||||
setIsAllPrivateChatsSidebarOpen(false);
|
setIsAllPrivateChatsSidebarOpen(false);
|
||||||
setIsDocumentsSidebarOpen(false);
|
|
||||||
setIsAnnouncementsSidebarOpen(false);
|
setIsAnnouncementsSidebarOpen(false);
|
||||||
}
|
}
|
||||||
return !prev;
|
return !prev;
|
||||||
|
|
@ -427,6 +446,18 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (item.url === "#documents") {
|
if (item.url === "#documents") {
|
||||||
|
if (!isMobile) {
|
||||||
|
if (!isDocumentsSidebarOpen) {
|
||||||
|
setIsDocumentsSidebarOpen(true);
|
||||||
|
setIsRightPanelCollapsed(false);
|
||||||
|
setIsInboxSidebarOpen(false);
|
||||||
|
setIsAllSharedChatsSidebarOpen(false);
|
||||||
|
setIsAllPrivateChatsSidebarOpen(false);
|
||||||
|
setIsAnnouncementsSidebarOpen(false);
|
||||||
|
} else {
|
||||||
|
setIsRightPanelCollapsed((prev) => !prev);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
setIsDocumentsSidebarOpen((prev) => {
|
setIsDocumentsSidebarOpen((prev) => {
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
setIsInboxSidebarOpen(false);
|
setIsInboxSidebarOpen(false);
|
||||||
|
|
@ -436,6 +467,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
}
|
}
|
||||||
return !prev;
|
return !prev;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (item.url === "#announcements") {
|
if (item.url === "#announcements") {
|
||||||
|
|
@ -444,7 +476,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
setIsInboxSidebarOpen(false);
|
setIsInboxSidebarOpen(false);
|
||||||
setIsAllSharedChatsSidebarOpen(false);
|
setIsAllSharedChatsSidebarOpen(false);
|
||||||
setIsAllPrivateChatsSidebarOpen(false);
|
setIsAllPrivateChatsSidebarOpen(false);
|
||||||
setIsDocumentsSidebarOpen(false);
|
|
||||||
}
|
}
|
||||||
return !prev;
|
return !prev;
|
||||||
});
|
});
|
||||||
|
|
@ -452,7 +483,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
}
|
}
|
||||||
router.push(item.url);
|
router.push(item.url);
|
||||||
},
|
},
|
||||||
[router, setIsDocumentsSidebarOpen]
|
[router, isMobile, isDocumentsSidebarOpen, setIsDocumentsSidebarOpen, setIsRightPanelCollapsed]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNewChat = useCallback(() => {
|
const handleNewChat = useCallback(() => {
|
||||||
|
|
@ -549,17 +580,15 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
setIsAllSharedChatsSidebarOpen(true);
|
setIsAllSharedChatsSidebarOpen(true);
|
||||||
setIsAllPrivateChatsSidebarOpen(false);
|
setIsAllPrivateChatsSidebarOpen(false);
|
||||||
setIsInboxSidebarOpen(false);
|
setIsInboxSidebarOpen(false);
|
||||||
setIsDocumentsSidebarOpen(false);
|
|
||||||
setIsAnnouncementsSidebarOpen(false);
|
setIsAnnouncementsSidebarOpen(false);
|
||||||
}, [setIsDocumentsSidebarOpen]);
|
}, []);
|
||||||
|
|
||||||
const handleViewAllPrivateChats = useCallback(() => {
|
const handleViewAllPrivateChats = useCallback(() => {
|
||||||
setIsAllPrivateChatsSidebarOpen(true);
|
setIsAllPrivateChatsSidebarOpen(true);
|
||||||
setIsAllSharedChatsSidebarOpen(false);
|
setIsAllSharedChatsSidebarOpen(false);
|
||||||
setIsInboxSidebarOpen(false);
|
setIsInboxSidebarOpen(false);
|
||||||
setIsDocumentsSidebarOpen(false);
|
|
||||||
setIsAnnouncementsSidebarOpen(false);
|
setIsAnnouncementsSidebarOpen(false);
|
||||||
}, [setIsDocumentsSidebarOpen]);
|
}, []);
|
||||||
|
|
||||||
// Delete handlers
|
// Delete handlers
|
||||||
const confirmDeleteChat = useCallback(async () => {
|
const confirmDeleteChat = useCallback(async () => {
|
||||||
|
|
@ -688,8 +717,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
markAsRead: statusInbox.markAsRead,
|
markAsRead: statusInbox.markAsRead,
|
||||||
markAllAsRead: statusInbox.markAllAsRead,
|
markAllAsRead: statusInbox.markAllAsRead,
|
||||||
},
|
},
|
||||||
isDocked: isInboxDocked,
|
|
||||||
onDockedChange: setIsInboxDocked,
|
|
||||||
}}
|
}}
|
||||||
announcementsPanel={{
|
announcementsPanel={{
|
||||||
open: isAnnouncementsSidebarOpen,
|
open: isAnnouncementsSidebarOpen,
|
||||||
|
|
@ -708,6 +735,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
documentsPanel={{
|
documentsPanel={{
|
||||||
open: isDocumentsSidebarOpen,
|
open: isDocumentsSidebarOpen,
|
||||||
onOpenChange: setIsDocumentsSidebarOpen,
|
onOpenChange: setIsDocumentsSidebarOpen,
|
||||||
|
isDocked: isDocumentsDocked,
|
||||||
|
onDockedChange: setIsDocumentsDocked,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Fragment key={chatResetKey}>{children}</Fragment>
|
<Fragment key={chatResetKey}>{children}</Fragment>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { PanelRight } from "lucide-react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
|
import { reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||||
|
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||||
|
import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||||
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
|
|
@ -15,6 +22,7 @@ interface HeaderProps {
|
||||||
export function Header({ mobileMenuTrigger }: HeaderProps) {
|
export function Header({ mobileMenuTrigger }: HeaderProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
||||||
|
|
||||||
|
|
@ -38,6 +46,13 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
||||||
|
|
||||||
const handleVisibilityChange = (_visibility: ChatVisibility) => {};
|
const handleVisibilityChange = (_visibility: ChatVisibility) => {};
|
||||||
|
|
||||||
|
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
|
||||||
|
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
|
||||||
|
const reportState = useAtomValue(reportPanelAtom);
|
||||||
|
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||||
|
const hasRightPanelContent = documentsOpen || reportOpen;
|
||||||
|
const showExpandButton = !isMobile && collapsed && hasRightPanelContent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4">
|
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4">
|
||||||
{/* Left side - Mobile menu trigger + Model selector */}
|
{/* Left side - Mobile menu trigger + Model selector */}
|
||||||
|
|
@ -49,10 +64,26 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Actions */}
|
{/* Right side - Actions */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2">
|
||||||
{hasThread && (
|
{hasThread && (
|
||||||
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
||||||
)}
|
)}
|
||||||
|
{showExpandButton && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setCollapsed(false)}
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
>
|
||||||
|
<PanelRight className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Expand panel</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Expand panel</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
168
surfsense_web/components/layout/ui/right-panel/RightPanel.tsx
Normal file
168
surfsense_web/components/layout/ui/right-panel/RightPanel.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import { PanelRight, PanelRightClose } from "lucide-react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { startTransition, useEffect } from "react";
|
||||||
|
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||||
|
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
|
||||||
|
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
|
||||||
|
import { ReportPanelContent } from "@/components/report-panel/report-panel";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { DocumentsSidebar } from "../sidebar";
|
||||||
|
|
||||||
|
interface RightPanelProps {
|
||||||
|
documentsPanel?: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapseButton({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" onClick={onClick} className="h-8 w-8 shrink-0">
|
||||||
|
<PanelRightClose className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Collapse panel</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left">Collapse panel</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolutely positioned expand button — renders at top-right of the main
|
||||||
|
* container so it occupies the same screen position as the collapse button
|
||||||
|
* inside the Documents header.
|
||||||
|
*/
|
||||||
|
export function RightPanelExpandButton() {
|
||||||
|
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
|
||||||
|
const documentsOpen = useAtomValue(documentsSidebarOpenAtom);
|
||||||
|
const reportState = useAtomValue(reportPanelAtom);
|
||||||
|
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||||
|
const hasContent = documentsOpen || reportOpen;
|
||||||
|
|
||||||
|
if (!collapsed || !hasContent) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-4 right-4 z-20">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => startTransition(() => setCollapsed(false))}
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
>
|
||||||
|
<PanelRight className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Expand panel</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left">Expand panel</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PANEL_WIDTHS = { sources: 420, report: 640 } as const;
|
||||||
|
|
||||||
|
export function RightPanel({ documentsPanel }: RightPanelProps) {
|
||||||
|
const [activeTab] = useAtom(rightPanelTabAtom);
|
||||||
|
const reportState = useAtomValue(reportPanelAtom);
|
||||||
|
const closeReport = useSetAtom(closeReportPanelAtom);
|
||||||
|
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
|
||||||
|
|
||||||
|
const documentsOpen = documentsPanel?.open ?? false;
|
||||||
|
const reportOpen = reportState.isOpen && !!reportState.reportId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!reportOpen) return;
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") closeReport();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [reportOpen, closeReport]);
|
||||||
|
|
||||||
|
const isVisible = (documentsOpen || reportOpen) && !collapsed;
|
||||||
|
|
||||||
|
const effectiveTab =
|
||||||
|
activeTab === "report" && !reportOpen
|
||||||
|
? "sources"
|
||||||
|
: activeTab === "sources" && !documentsOpen
|
||||||
|
? "report"
|
||||||
|
: activeTab;
|
||||||
|
|
||||||
|
const targetWidth = PANEL_WIDTHS[effectiveTab];
|
||||||
|
const collapseButton = <CollapseButton onClick={() => setCollapsed(true)} />;
|
||||||
|
|
||||||
|
const contentKey =
|
||||||
|
effectiveTab === "sources" && documentsOpen
|
||||||
|
? "sources"
|
||||||
|
: effectiveTab === "report" && reportOpen
|
||||||
|
? "report"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isVisible && (
|
||||||
|
<motion.aside
|
||||||
|
key="right-panel"
|
||||||
|
initial={{ width: 0, opacity: 0 }}
|
||||||
|
animate={{ width: targetWidth, opacity: 1 }}
|
||||||
|
exit={{ width: 0, opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
width: { type: "spring", stiffness: 400, damping: 35, mass: 0.8 },
|
||||||
|
opacity: { duration: 0.2, ease: "easeOut" },
|
||||||
|
}}
|
||||||
|
style={{ willChange: "width, opacity", contain: "layout style" }}
|
||||||
|
className="flex h-full shrink-0 flex-col border-l bg-background overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||||
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
|
{contentKey === "sources" && documentsPanel && (
|
||||||
|
<motion.div
|
||||||
|
key="sources"
|
||||||
|
initial={{ opacity: 0, x: 8 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -8 }}
|
||||||
|
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
|
<DocumentsSidebar
|
||||||
|
open={documentsPanel.open}
|
||||||
|
onOpenChange={documentsPanel.onOpenChange}
|
||||||
|
embedded
|
||||||
|
headerAction={collapseButton}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
{contentKey === "report" && (
|
||||||
|
<motion.div
|
||||||
|
key="report"
|
||||||
|
initial={{ opacity: 0, x: 8 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -8 }}
|
||||||
|
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<ReportPanelContent
|
||||||
|
reportId={reportState.reportId!}
|
||||||
|
title={reportState.title || "Report"}
|
||||||
|
onClose={closeReport}
|
||||||
|
shareToken={reportState.shareToken}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</motion.aside>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "motion/react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import type { InboxItem } from "@/hooks/use-inbox";
|
import type { InboxItem } from "@/hooks/use-inbox";
|
||||||
|
|
@ -10,6 +11,7 @@ import { useSidebarResize } from "../../hooks/useSidebarResize";
|
||||||
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||||
import { Header } from "../header";
|
import { Header } from "../header";
|
||||||
import { IconRail } from "../icon-rail";
|
import { IconRail } from "../icon-rail";
|
||||||
|
import { RightPanel } from "../right-panel/RightPanel";
|
||||||
import {
|
import {
|
||||||
AllPrivateChatsSidebar,
|
AllPrivateChatsSidebar,
|
||||||
AllSharedChatsSidebar,
|
AllSharedChatsSidebar,
|
||||||
|
|
@ -40,8 +42,6 @@ interface InboxProps {
|
||||||
totalUnreadCount: number;
|
totalUnreadCount: number;
|
||||||
comments: TabDataSource;
|
comments: TabDataSource;
|
||||||
status: TabDataSource;
|
status: TabDataSource;
|
||||||
isDocked?: boolean;
|
|
||||||
onDockedChange?: (docked: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LayoutShellProps {
|
interface LayoutShellProps {
|
||||||
|
|
@ -97,6 +97,8 @@ interface LayoutShellProps {
|
||||||
documentsPanel?: {
|
documentsPanel?: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
isDocked?: boolean;
|
||||||
|
onDockedChange?: (docked: boolean) => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,45 +308,36 @@ export function LayoutShell({
|
||||||
isResizing={isResizing}
|
isResizing={isResizing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Docked Inbox Sidebar - renders as flex sibling between sidebar and content */}
|
<motion.main
|
||||||
{inbox?.isDocked && (
|
layout="position"
|
||||||
<InboxSidebar
|
style={{ contain: "inline-size" }}
|
||||||
open={inbox.isOpen}
|
className="flex-1 flex flex-col min-w-0"
|
||||||
onOpenChange={inbox.onOpenChange}
|
>
|
||||||
comments={inbox.comments}
|
|
||||||
status={inbox.status}
|
|
||||||
totalUnreadCount={inbox.totalUnreadCount}
|
|
||||||
isDocked={inbox.isDocked}
|
|
||||||
onDockedChange={inbox.onDockedChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</motion.main>
|
||||||
|
|
||||||
{/* Floating Inbox Sidebar - positioned absolutely on top of content */}
|
{/* Right panel — tabbed Sources/Report (desktop only) */}
|
||||||
{inbox && !inbox.isDocked && (
|
{documentsPanel && (
|
||||||
|
<RightPanel
|
||||||
|
documentsPanel={{
|
||||||
|
open: documentsPanel.open,
|
||||||
|
onOpenChange: documentsPanel.onOpenChange,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inbox Sidebar - slide-out panel */}
|
||||||
|
{inbox && (
|
||||||
<InboxSidebar
|
<InboxSidebar
|
||||||
open={inbox.isOpen}
|
open={inbox.isOpen}
|
||||||
onOpenChange={inbox.onOpenChange}
|
onOpenChange={inbox.onOpenChange}
|
||||||
comments={inbox.comments}
|
comments={inbox.comments}
|
||||||
status={inbox.status}
|
status={inbox.status}
|
||||||
totalUnreadCount={inbox.totalUnreadCount}
|
totalUnreadCount={inbox.totalUnreadCount}
|
||||||
isDocked={false}
|
|
||||||
onDockedChange={inbox.onDockedChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Documents Sidebar - slide-out panel */}
|
|
||||||
{documentsPanel && (
|
|
||||||
<DocumentsSidebar
|
|
||||||
open={documentsPanel.open}
|
|
||||||
onOpenChange={documentsPanel.onOpenChange}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { ArchiveIcon, MoreHorizontal, PenLine, RotateCcwIcon, Trash2 } from "lucide-react";
|
||||||
ArchiveIcon,
|
|
||||||
MessageSquare,
|
|
||||||
MoreHorizontal,
|
|
||||||
PenLine,
|
|
||||||
RotateCcwIcon,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -64,21 +57,26 @@ export function ChatListItem({
|
||||||
{...(isMobile ? longPressHandlers : {})}
|
{...(isMobile ? longPressHandlers : {})}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
|
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
|
||||||
"[&>span:last-child]:truncate",
|
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
|
||||||
"hover:bg-accent hover:text-accent-foreground",
|
|
||||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
isActive && "bg-accent text-accent-foreground"
|
isActive && "bg-accent text-accent-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<span className="truncate">{animatedName}</span>
|
||||||
<span className="w-[calc(100%-3rem)] ">{animatedName}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
|
{/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-1 top-1/2 -translate-y-1/2 transition-opacity",
|
"absolute right-0 top-0 bottom-0 flex items-center pr-1 pl-6 rounded-r-md",
|
||||||
isMobile ? "opacity-0 pointer-events-none" : "opacity-0 group-hover/item:opacity-100"
|
isActive
|
||||||
|
? "bg-gradient-to-l from-accent from-60% to-transparent"
|
||||||
|
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
|
||||||
|
isMobile
|
||||||
|
? "opacity-0 pointer-events-none"
|
||||||
|
: isActive
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0 group-hover/item:opacity-100"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import { ChevronLeft } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Unplug } from "lucide-react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
@ -12,8 +12,12 @@ import {
|
||||||
type SortKey,
|
type SortKey,
|
||||||
} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell";
|
} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell";
|
||||||
import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||||
|
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||||
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||||
|
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import { useDocumentSearch } from "@/hooks/use-document-search";
|
import { useDocumentSearch } from "@/hooks/use-document-search";
|
||||||
|
|
@ -21,17 +25,43 @@ import { useDocuments } from "@/hooks/use-documents";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||||
|
|
||||||
|
const SHOWCASE_CONNECTORS = [
|
||||||
|
{ type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" },
|
||||||
|
{ type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" },
|
||||||
|
{ type: "NOTION_CONNECTOR", label: "Notion" },
|
||||||
|
{ type: "YOUTUBE_CONNECTOR", label: "YouTube" },
|
||||||
|
{ type: "GOOGLE_CALENDAR_CONNECTOR", label: "Google Calendar" },
|
||||||
|
{ type: "SLACK_CONNECTOR", label: "Slack" },
|
||||||
|
{ type: "LINEAR_CONNECTOR", label: "Linear" },
|
||||||
|
{ type: "JIRA_CONNECTOR", label: "Jira" },
|
||||||
|
{ type: "GITHUB_CONNECTOR", label: "GitHub" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
interface DocumentsSidebarProps {
|
interface DocumentsSidebarProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
isDocked?: boolean;
|
||||||
|
onDockedChange?: (docked: boolean) => void;
|
||||||
|
/** When true, renders content without any wrapper — parent provides the container */
|
||||||
|
embedded?: boolean;
|
||||||
|
/** Optional action element rendered in the header row (e.g. collapse button) */
|
||||||
|
headerAction?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) {
|
export function DocumentsSidebar({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
isDocked = false,
|
||||||
|
onDockedChange,
|
||||||
|
embedded = false,
|
||||||
|
headerAction,
|
||||||
|
}: DocumentsSidebarProps) {
|
||||||
const t = useTranslations("documents");
|
const t = useTranslations("documents");
|
||||||
const tSidebar = useTranslations("sidebar");
|
const tSidebar = useTranslations("sidebar");
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||||
const searchSpaceId = Number(params.search_space_id);
|
const searchSpaceId = Number(params.search_space_id);
|
||||||
|
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const debouncedSearch = useDebouncedValue(search, 250);
|
const debouncedSearch = useDebouncedValue(search, 250);
|
||||||
|
|
@ -148,8 +178,8 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
|
||||||
|
|
||||||
const documentsContent = (
|
const documentsContent = (
|
||||||
<>
|
<>
|
||||||
<div className="shrink-0 p-4 pb-10">
|
<div className="shrink-0 flex h-14 items-center px-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -162,11 +192,71 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
|
||||||
<span className="sr-only">{tSidebar("close") || "Close"}</span>
|
<span className="sr-only">{tSidebar("close") || "Close"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<h2 className="text-lg font-semibold">{t("title") || "Documents"}</h2>
|
<h2 className="select-none text-lg font-semibold">{t("title") || "Documents"}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{!isMobile && onDockedChange && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
onClick={() => {
|
||||||
|
if (isDocked) {
|
||||||
|
onDockedChange(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
} else {
|
||||||
|
onDockedChange(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDocked ? (
|
||||||
|
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="z-80">
|
||||||
|
{isDocked ? "Collapse panel" : "Expand panel"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{headerAction}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Connected tools strip */}
|
||||||
|
<div className="shrink-0 mx-4 mt-2 mb-3 flex select-none items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConnectorDialogOpen(true)}
|
||||||
|
className="flex items-center gap-2 min-w-0 flex-1 text-left"
|
||||||
|
>
|
||||||
|
<Unplug className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate text-xs text-muted-foreground">Connect your tools</span>
|
||||||
|
<AvatarGroup className="ml-auto shrink-0">
|
||||||
|
{SHOWCASE_CONNECTORS.map(({ type, label }, i) => (
|
||||||
|
<Tooltip key={type}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Avatar className="size-6" style={{ zIndex: SHOWCASE_CONNECTORS.length - i }}>
|
||||||
|
<AvatarFallback className="bg-muted text-[10px]">
|
||||||
|
{getConnectorIcon(type, "size-3.5")}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="text-xs">
|
||||||
|
{label}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</AvatarGroup>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-x-hidden pt-0 flex flex-col">
|
<div className="flex-1 min-h-0 overflow-x-hidden pt-0 flex flex-col">
|
||||||
<div className="px-4 pb-2">
|
<div className="px-4 pb-2">
|
||||||
<DocumentsFilters
|
<DocumentsFilters
|
||||||
|
|
@ -199,12 +289,31 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
|
||||||
|
{documentsContent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDocked && open && !isMobile) {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="h-full w-[380px] shrink-0 bg-sidebar text-sidebar-foreground flex flex-col border-r"
|
||||||
|
aria-label={t("title") || "Documents"}
|
||||||
|
>
|
||||||
|
{documentsContent}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarSlideOutPanel
|
<SidebarSlideOutPanel
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
ariaLabel={t("title") || "Documents"}
|
ariaLabel={t("title") || "Documents"}
|
||||||
width={isMobile ? undefined : 480}
|
width={isMobile ? undefined : 380}
|
||||||
>
|
>
|
||||||
{documentsContent}
|
{documentsContent}
|
||||||
</SidebarSlideOutPanel>
|
</SidebarSlideOutPanel>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
|
||||||
History,
|
History,
|
||||||
Inbox,
|
Inbox,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
|
@ -23,7 +22,7 @@ import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||||
import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
import { setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -148,8 +147,6 @@ interface InboxSidebarProps {
|
||||||
status: TabDataSource;
|
status: TabDataSource;
|
||||||
totalUnreadCount: number;
|
totalUnreadCount: number;
|
||||||
onCloseMobileSidebar?: () => void;
|
onCloseMobileSidebar?: () => void;
|
||||||
isDocked?: boolean;
|
|
||||||
onDockedChange?: (docked: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InboxSidebar({
|
export function InboxSidebar({
|
||||||
|
|
@ -159,8 +156,6 @@ export function InboxSidebar({
|
||||||
status,
|
status,
|
||||||
totalUnreadCount,
|
totalUnreadCount,
|
||||||
onCloseMobileSidebar,
|
onCloseMobileSidebar,
|
||||||
isDocked = false,
|
|
||||||
onDockedChange,
|
|
||||||
}: InboxSidebarProps) {
|
}: InboxSidebarProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -168,7 +163,6 @@ export function InboxSidebar({
|
||||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||||
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
|
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
|
||||||
|
|
||||||
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
|
|
||||||
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
@ -822,37 +816,6 @@ export function InboxSidebar({
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{!isMobile && onDockedChange && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-full"
|
|
||||||
onClick={() => {
|
|
||||||
if (isDocked) {
|
|
||||||
setCommentsCollapsed(false);
|
|
||||||
onDockedChange(false);
|
|
||||||
onOpenChange(false);
|
|
||||||
} else {
|
|
||||||
setCommentsCollapsed(true);
|
|
||||||
onDockedChange(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isDocked ? (
|
|
||||||
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<span className="sr-only">{isDocked ? "Collapse panel" : "Expand panel"}</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="z-80">
|
|
||||||
{isDocked ? "Collapse panel" : "Expand panel"}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1095,17 +1058,6 @@ export function InboxSidebar({
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isDocked && open && !isMobile) {
|
|
||||||
return (
|
|
||||||
<aside
|
|
||||||
className="h-full w-[360px] shrink-0 bg-sidebar text-sidebar-foreground flex flex-col border-r"
|
|
||||||
aria-label={t("inbox") || "Inbox"}
|
|
||||||
>
|
|
||||||
{inboxContent}
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}>
|
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}>
|
||||||
{inboxContent}
|
{inboxContent}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useSidebarContextSafe } from "../../hooks";
|
import { useSidebarContextSafe } from "../../hooks";
|
||||||
|
|
||||||
|
export const SLIDEOUT_PANEL_OPENED_EVENT = "slideout-panel-opened";
|
||||||
|
|
||||||
const SIDEBAR_COLLAPSED_WIDTH = 60;
|
const SIDEBAR_COLLAPSED_WIDTH = 60;
|
||||||
|
|
||||||
interface SidebarSlideOutPanelProps {
|
interface SidebarSlideOutPanelProps {
|
||||||
|
|
@ -36,20 +39,29 @@ export function SidebarSlideOutPanel({
|
||||||
? SIDEBAR_COLLAPSED_WIDTH
|
? SIDEBAR_COLLAPSED_WIDTH
|
||||||
: (sidebarContext?.sidebarWidth ?? 240);
|
: (sidebarContext?.sidebarWidth ?? 240);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
window.dispatchEvent(new Event(SLIDEOUT_PANEL_OPENED_EVENT));
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
{/* Click-away layer - covers the full container including the sidebar */}
|
{/* Backdrop overlay with blur — desktop only, covers main content area (right of sidebar) */}
|
||||||
|
{!isMobile && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
className="absolute inset-0 z-[5]"
|
style={{ left: sidebarWidth }}
|
||||||
|
className="absolute inset-y-0 right-0 z-20 bg-black/30 backdrop-blur-sm"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Clip container - positioned at sidebar edge with overflow hidden */}
|
{/* Clip container - positioned at sidebar edge with overflow hidden */}
|
||||||
<div
|
<div
|
||||||
|
|
@ -57,7 +69,7 @@ export function SidebarSlideOutPanel({
|
||||||
left: isMobile ? 0 : sidebarWidth,
|
left: isMobile ? 0 : sidebarWidth,
|
||||||
width: isMobile ? "100%" : width,
|
width: isMobile ? "100%" : width,
|
||||||
}}
|
}}
|
||||||
className={cn("absolute z-10 overflow-hidden pointer-events-none", "inset-y-0")}
|
className={cn("absolute z-30 overflow-hidden pointer-events-none", "inset-y-0")}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ x: "-100%" }}
|
initial={{ x: "-100%" }}
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,9 @@ function ReportPanelSkeleton() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inner content component used by both desktop panel and mobile drawer
|
* Inner content component used by desktop panel, mobile drawer, and the layout right panel
|
||||||
*/
|
*/
|
||||||
function ReportPanelContent({
|
export function ReportPanelContent({
|
||||||
reportId,
|
reportId,
|
||||||
title,
|
title,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -294,32 +294,11 @@ function ReportPanelContent({
|
||||||
}
|
}
|
||||||
}, [activeReportId, currentMarkdown]);
|
}, [activeReportId, currentMarkdown]);
|
||||||
|
|
||||||
// Show full-page skeleton only on initial load (no data loaded yet).
|
|
||||||
// Once we have versions/content from a prior fetch, keep the action bar visible.
|
|
||||||
const hasLoadedBefore = versions.length > 0 || reportContent !== null;
|
|
||||||
|
|
||||||
if (isLoading && !hasLoadedBefore) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Minimal top bar with close button even during initial load */}
|
|
||||||
<div className="flex items-center justify-end px-4 py-2 shrink-0">
|
|
||||||
{onClose && (
|
|
||||||
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
|
|
||||||
<XIcon className="size-4" />
|
|
||||||
<span className="sr-only">Close report panel</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ReportPanelSkeleton />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId);
|
const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Action bar — always visible after initial load */}
|
{/* Action bar — always visible; buttons are disabled while loading */}
|
||||||
<div className="flex items-center justify-between px-4 py-2 shrink-0">
|
<div className="flex items-center justify-between px-4 py-2 shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Copy button */}
|
{/* Copy button */}
|
||||||
|
|
@ -352,27 +331,51 @@ function ReportPanelContent({
|
||||||
>
|
>
|
||||||
{!shareToken && (
|
{!shareToken && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Documents</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||||
<DropdownMenuItem onClick={() => handleExport("pdf")} disabled={exporting !== null}>
|
Documents
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleExport("pdf")}
|
||||||
|
disabled={exporting !== null}
|
||||||
|
>
|
||||||
PDF (.pdf)
|
PDF (.pdf)
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleExport("docx")} disabled={exporting !== null}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleExport("docx")}
|
||||||
|
disabled={exporting !== null}
|
||||||
|
>
|
||||||
Word (.docx)
|
Word (.docx)
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleExport("odt")} disabled={exporting !== null}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleExport("odt")}
|
||||||
|
disabled={exporting !== null}
|
||||||
|
>
|
||||||
OpenDocument (.odt)
|
OpenDocument (.odt)
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Web & E-Book</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||||
<DropdownMenuItem onClick={() => handleExport("html")} disabled={exporting !== null}>
|
Web & E-Book
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleExport("html")}
|
||||||
|
disabled={exporting !== null}
|
||||||
|
>
|
||||||
HTML (.html)
|
HTML (.html)
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleExport("epub")} disabled={exporting !== null}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleExport("epub")}
|
||||||
|
disabled={exporting !== null}
|
||||||
|
>
|
||||||
EPUB (.epub)
|
EPUB (.epub)
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">Source & Plain</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||||
<DropdownMenuItem onClick={() => handleExport("latex")} disabled={exporting !== null}>
|
Source & Plain
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleExport("latex")}
|
||||||
|
disabled={exporting !== null}
|
||||||
|
>
|
||||||
LaTeX (.tex)
|
LaTeX (.tex)
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
|
|
@ -381,7 +384,10 @@ function ReportPanelContent({
|
||||||
Markdown (.md)
|
Markdown (.md)
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{!shareToken && (
|
{!shareToken && (
|
||||||
<DropdownMenuItem onClick={() => handleExport("plain")} disabled={exporting !== null}>
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleExport("plain")}
|
||||||
|
disabled={exporting !== null}
|
||||||
|
>
|
||||||
Plain Text (.txt)
|
Plain Text (.txt)
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
@ -538,9 +544,6 @@ function MobileReportDrawer() {
|
||||||
*
|
*
|
||||||
* On desktop (lg+): Renders as a right-side split panel (flex sibling to the chat thread)
|
* On desktop (lg+): Renders as a right-side split panel (flex sibling to the chat thread)
|
||||||
* On mobile/tablet: Renders as a Vaul bottom drawer
|
* On mobile/tablet: Renders as a Vaul bottom drawer
|
||||||
*
|
|
||||||
* When open on desktop, the comments gutter is automatically suppressed
|
|
||||||
* (handled via showCommentsGutterAtom in current-thread.atom.ts)
|
|
||||||
*/
|
*/
|
||||||
export function ReportPanel() {
|
export function ReportPanel() {
|
||||||
const panelState = useAtomValue(reportPanelAtom);
|
const panelState = useAtomValue(reportPanelAtom);
|
||||||
|
|
@ -555,3 +558,18 @@ export function ReportPanel() {
|
||||||
|
|
||||||
return <MobileReportDrawer />;
|
return <MobileReportDrawer />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MobileReportPanel — mobile-only report drawer
|
||||||
|
*
|
||||||
|
* Used in the dashboard chat page where the desktop report is handled
|
||||||
|
* by the layout-level RightPanel instead.
|
||||||
|
*/
|
||||||
|
export function MobileReportPanel() {
|
||||||
|
const panelState = useAtomValue(reportPanelAtom);
|
||||||
|
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||||
|
|
||||||
|
if (isDesktop || !panelState.isOpen || !panelState.reportId) return null;
|
||||||
|
|
||||||
|
return <MobileReportDrawer />;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -348,7 +348,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||||
|
|
||||||
{/* Global info */}
|
{/* Global info */}
|
||||||
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
|
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
|
||||||
<Alert className="flex flex-row items-center gap-2 bg-muted/50 py-3 [&>svg]:static [&>svg+div]:translate-y-0 [&>svg~*]:pl-0">
|
<Alert className="bg-muted/50 py-3">
|
||||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||||
<AlertDescription className="text-xs md:text-sm">
|
<AlertDescription className="text-xs md:text-sm">
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
|
|
|
||||||
|
|
@ -700,7 +700,12 @@ function PermissionsEditor({
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-muted/40 transition-colors"
|
className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-muted/40 transition-colors"
|
||||||
onClick={() => toggleCategoryExpanded(category)}
|
onClick={() => toggleCategoryExpanded(category)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleCategoryExpanded(category); } }}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleCategoryExpanded(category);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<IconComponent className="h-4 w-4 text-muted-foreground shrink-0" />
|
<IconComponent className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
|
@ -763,7 +768,12 @@ function PermissionsEditor({
|
||||||
isSelected ? "bg-muted/60 hover:bg-muted/80" : "hover:bg-muted/40"
|
isSelected ? "bg-muted/60 hover:bg-muted/80" : "hover:bg-muted/40"
|
||||||
)}
|
)}
|
||||||
onClick={() => onTogglePermission(perm.value)}
|
onClick={() => onTogglePermission(perm.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onTogglePermission(perm.value); } }}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onTogglePermission(perm.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0 text-left">
|
<div className="flex-1 min-w-0 text-left">
|
||||||
<span className="text-sm font-medium">{actionLabel}</span>
|
<span className="text-sm font-medium">{actionLabel}</span>
|
||||||
|
|
|
||||||
|
|
@ -254,7 +254,7 @@ export function DocumentUploadTab({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0">
|
<div className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0">
|
||||||
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5 flex items-start gap-3 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg~*]:pl-0">
|
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5">
|
||||||
<Info className="h-4 w-4 shrink-0 mt-0.5" />
|
<Info className="h-4 w-4 shrink-0 mt-0.5" />
|
||||||
<AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5">
|
<AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5">
|
||||||
{t("file_size_limit")}{" "}
|
{t("file_size_limit")}{" "}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import * as React from "react";
|
import type * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const alertVariants = cva(
|
const alertVariants = cva(
|
||||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-background text-foreground",
|
default: "bg-card text-card-foreground",
|
||||||
destructive:
|
destructive:
|
||||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|
@ -18,31 +19,42 @@ const alertVariants = cva(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const Alert = React.forwardRef<
|
function Alert({
|
||||||
HTMLDivElement,
|
className,
|
||||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
variant,
|
||||||
>(({ className, variant, ...props }, ref) => (
|
...props
|
||||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
));
|
return (
|
||||||
Alert.displayName = "Alert";
|
<div
|
||||||
|
data-slot="alert"
|
||||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
role="alert"
|
||||||
({ className, ...props }, ref) => (
|
className={cn(alertVariants({ variant }), className)}
|
||||||
<h5
|
|
||||||
ref={ref}
|
|
||||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
|
||||||
);
|
);
|
||||||
AlertTitle.displayName = "AlertTitle";
|
}
|
||||||
|
|
||||||
const AlertDescription = React.forwardRef<
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
HTMLParagraphElement,
|
return (
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
<div
|
||||||
>(({ className, ...props }, ref) => (
|
data-slot="alert-title"
|
||||||
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
|
||||||
));
|
{...props}
|
||||||
AlertDescription.displayName = "AlertDescription";
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export { Alert, AlertTitle, AlertDescription };
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
|
|
|
||||||
|
|
@ -38,4 +38,21 @@ function AvatarFallback({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Avatar, AvatarImage, AvatarFallback };
|
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div data-slot="avatar-group" className={cn("flex -space-x-2", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 items-center justify-center rounded-full border-2 border-background bg-muted text-xs font-medium text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount };
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,7 @@ function DropdownMenuSeparator({
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
data-slot="dropdown-menu-separator"
|
data-slot="dropdown-menu-separator"
|
||||||
className={cn("bg-border dark:bg-neutral-700 -mx-1 my-1 h-px", className)}
|
className={cn("bg-border mx-2 my-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ExpandedGifOverlay, useExpandedGif } from "@/components/ui/expanded-gif-overlay";
|
import { ExpandedGifOverlay, useExpandedGif } from "@/components/ui/expanded-gif-overlay";
|
||||||
|
|
||||||
|
|
@ -58,51 +58,29 @@ function HeroCarouselCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
src,
|
src,
|
||||||
isActive,
|
|
||||||
onExpandedChange,
|
onExpandedChange,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
src: string;
|
src: string;
|
||||||
isActive: boolean;
|
|
||||||
onExpandedChange?: (expanded: boolean) => void;
|
onExpandedChange?: (expanded: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { expanded, open, close } = useExpandedGif();
|
const { expanded, open, close } = useExpandedGif();
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const [frozenFrame, setFrozenFrame] = useState<string | null>(null);
|
|
||||||
const [hasLoaded, setHasLoaded] = useState(false);
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onExpandedChange?.(expanded);
|
onExpandedChange?.(expanded);
|
||||||
}, [expanded, onExpandedChange]);
|
}, [expanded, onExpandedChange]);
|
||||||
|
|
||||||
const captureFrame = useCallback((video: HTMLVideoElement) => {
|
|
||||||
try {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = video.videoWidth;
|
|
||||||
canvas.height = video.videoHeight;
|
|
||||||
canvas.getContext("2d")?.drawImage(video, 0, 0);
|
|
||||||
setFrozenFrame(canvas.toDataURL("image/jpeg", 0.85));
|
|
||||||
} catch {
|
|
||||||
/* tainted canvas */
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (isActive) {
|
|
||||||
setHasLoaded(false);
|
|
||||||
if (video) {
|
if (video) {
|
||||||
|
setHasLoaded(false);
|
||||||
video.currentTime = 0;
|
video.currentTime = 0;
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => {});
|
||||||
}
|
}
|
||||||
} else {
|
}, [src]);
|
||||||
if (video) {
|
|
||||||
if (video.readyState >= 2) captureFrame(video);
|
|
||||||
video.pause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isActive, captureFrame]);
|
|
||||||
|
|
||||||
const handleCanPlay = useCallback(() => {
|
const handleCanPlay = useCallback(() => {
|
||||||
setHasLoaded(true);
|
setHasLoaded(true);
|
||||||
|
|
@ -119,13 +97,7 @@ function HeroCarouselCard({
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">{description}</p>
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="cursor-pointer bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950" onClick={open}>
|
||||||
className={`bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950 ${
|
|
||||||
isActive ? "cursor-pointer" : "pointer-events-none"
|
|
||||||
}`}
|
|
||||||
onClick={isActive ? open : undefined}
|
|
||||||
>
|
|
||||||
{isActive ? (
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
|
|
@ -137,22 +109,10 @@ function HeroCarouselCard({
|
||||||
onCanPlay={handleCanPlay}
|
onCanPlay={handleCanPlay}
|
||||||
className="w-full rounded-lg sm:rounded-xl"
|
className="w-full rounded-lg sm:rounded-xl"
|
||||||
/>
|
/>
|
||||||
{!hasLoaded && frozenFrame && (
|
{!hasLoaded && (
|
||||||
<img
|
<div className="absolute inset-0 aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
|
||||||
src={frozenFrame}
|
|
||||||
alt={title}
|
|
||||||
className="absolute inset-0 w-full rounded-lg sm:rounded-xl"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!hasLoaded && !frozenFrame && (
|
|
||||||
<div className="aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : frozenFrame ? (
|
|
||||||
<img src={frozenFrame} alt={title} className="w-full rounded-lg sm:rounded-xl" />
|
|
||||||
) : (
|
|
||||||
<div className="aspect-video w-full rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -163,15 +123,42 @@ function HeroCarouselCard({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function usePrefetchVideos() {
|
||||||
|
const videosRef = useRef<HTMLVideoElement[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function prefetch() {
|
||||||
|
for (const item of carouselItems) {
|
||||||
|
if (cancelled) break;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.preload = "auto";
|
||||||
|
video.src = item.src;
|
||||||
|
video.oncanplaythrough = () => resolve();
|
||||||
|
video.onerror = () => resolve();
|
||||||
|
setTimeout(resolve, 10000);
|
||||||
|
videosRef.current.push(video);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prefetch();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
videosRef.current = [];
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
function HeroCarousel() {
|
function HeroCarousel() {
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const [isGifExpanded, setIsGifExpanded] = useState(false);
|
const [isGifExpanded, setIsGifExpanded] = useState(false);
|
||||||
const [containerWidth, setContainerWidth] = useState(0);
|
|
||||||
const [cardHeight, setCardHeight] = useState(420);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const activeCardRef = useRef<HTMLDivElement>(null);
|
|
||||||
const directionRef = useRef<"forward" | "backward">("forward");
|
const directionRef = useRef<"forward" | "backward">("forward");
|
||||||
|
|
||||||
|
usePrefetchVideos();
|
||||||
|
|
||||||
const goTo = useCallback(
|
const goTo = useCallback(
|
||||||
(newIndex: number) => {
|
(newIndex: number) => {
|
||||||
directionRef.current = newIndex >= activeIndex ? "forward" : "backward";
|
directionRef.current = newIndex >= activeIndex ? "forward" : "backward";
|
||||||
|
|
@ -188,120 +175,28 @@ function HeroCarousel() {
|
||||||
goTo(activeIndex >= carouselItems.length - 1 ? 0 : activeIndex + 1);
|
goTo(activeIndex >= carouselItems.length - 1 ? 0 : activeIndex + 1);
|
||||||
}, [activeIndex, goTo]);
|
}, [activeIndex, goTo]);
|
||||||
|
|
||||||
useEffect(() => {
|
const item = carouselItems[activeIndex];
|
||||||
const el = containerRef.current;
|
const isForward = directionRef.current === "forward";
|
||||||
if (!el) return;
|
|
||||||
const update = () => setContainerWidth(el.offsetWidth);
|
|
||||||
update();
|
|
||||||
const observer = new ResizeObserver(update);
|
|
||||||
observer.observe(el);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = activeCardRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const update = () => setCardHeight(el.offsetHeight);
|
|
||||||
update();
|
|
||||||
const observer = new ResizeObserver(update);
|
|
||||||
observer.observe(el);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [activeIndex, containerWidth]);
|
|
||||||
|
|
||||||
const cardWidth =
|
|
||||||
containerWidth < 640
|
|
||||||
? containerWidth * 0.85
|
|
||||||
: containerWidth < 1024
|
|
||||||
? Math.min(containerWidth * 0.7, 680)
|
|
||||||
: Math.min(containerWidth * 0.55, 900);
|
|
||||||
|
|
||||||
const baseOffset =
|
|
||||||
containerWidth < 640
|
|
||||||
? containerWidth * 0.2
|
|
||||||
: containerWidth < 1024
|
|
||||||
? containerWidth * 0.15
|
|
||||||
: 150;
|
|
||||||
|
|
||||||
const stackGap = containerWidth < 640 ? 35 : containerWidth < 1024 ? 45 : 55;
|
|
||||||
const perspective = containerWidth < 640 ? 800 : containerWidth < 1024 ? 1000 : 1200;
|
|
||||||
|
|
||||||
const getCardStyle = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
const diff = index - activeIndex;
|
|
||||||
|
|
||||||
if (diff === 0) {
|
|
||||||
const originX = directionRef.current === "forward" ? 1 : 0;
|
|
||||||
return { x: -cardWidth / 2, rotateY: 0, zIndex: 20, originX, overlayOpacity: 0, blur: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const dist = Math.abs(diff);
|
|
||||||
const isLeft = diff < 0;
|
|
||||||
const offset = baseOffset + (dist - 1) * stackGap;
|
|
||||||
const t = Math.min(1, dist / 3);
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: -cardWidth / 2 + (isLeft ? -offset : offset),
|
|
||||||
rotateY: isLeft ? 90 : -90,
|
|
||||||
zIndex: 20 - dist,
|
|
||||||
originX: isLeft ? 0 : 1,
|
|
||||||
overlayOpacity: t,
|
|
||||||
blur: t * 6,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[activeIndex, cardWidth, baseOffset, stackGap]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full py-4 sm:py-8">
|
<div className="w-full py-4 sm:py-8">
|
||||||
<div ref={containerRef} className="relative mx-auto w-full">
|
<div className="relative mx-auto w-full max-w-[900px]">
|
||||||
<div
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
className="relative z-6 transition-[height] duration-700"
|
|
||||||
style={{ perspective: `${perspective}px`, height: cardHeight }}
|
|
||||||
>
|
|
||||||
{containerWidth > 0 &&
|
|
||||||
carouselItems.map((item, i) => {
|
|
||||||
const style = getCardStyle(i);
|
|
||||||
return (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`carousel_${i}`}
|
key={activeIndex}
|
||||||
ref={i === activeIndex ? activeCardRef : undefined}
|
initial={{ opacity: 0, x: isForward ? 60 : -60 }}
|
||||||
className="absolute top-0"
|
animate={{ opacity: 1, x: 0 }}
|
||||||
style={{
|
exit={{ opacity: 0, x: isForward ? -60 : 60 }}
|
||||||
left: "50%",
|
transition={{ duration: 0.35, ease: [0.32, 0.72, 0, 1] }}
|
||||||
width: cardWidth,
|
|
||||||
transformStyle: "preserve-3d",
|
|
||||||
zIndex: style.zIndex,
|
|
||||||
transformOrigin: `${style.originX * 100}% 50%`,
|
|
||||||
cursor: i !== activeIndex ? "pointer" : undefined,
|
|
||||||
}}
|
|
||||||
onClick={i !== activeIndex && !isGifExpanded ? () => goTo(i) : undefined}
|
|
||||||
animate={{
|
|
||||||
x: style.x,
|
|
||||||
rotateY: style.rotateY,
|
|
||||||
}}
|
|
||||||
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
animate={{ filter: `blur(${style.blur}px)` }}
|
|
||||||
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
|
|
||||||
>
|
>
|
||||||
<HeroCarouselCard
|
<HeroCarouselCard
|
||||||
title={item.title}
|
title={item.title}
|
||||||
description={item.description}
|
description={item.description}
|
||||||
src={item.src}
|
src={item.src}
|
||||||
isActive={i === activeIndex}
|
|
||||||
onExpandedChange={setIsGifExpanded}
|
onExpandedChange={setIsGifExpanded}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<motion.div
|
</AnimatePresence>
|
||||||
className="pointer-events-none absolute inset-0 rounded-2xl bg-black sm:rounded-3xl"
|
|
||||||
animate={{ opacity: style.overlayOpacity }}
|
|
||||||
transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-5 mt-6 flex items-center justify-center gap-4">
|
<div className="relative z-5 mt-6 flex items-center justify-center gap-4">
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
<Sonner
|
<Sonner
|
||||||
theme={theme as ToasterProps["theme"]}
|
theme={theme as ToasterProps["theme"]}
|
||||||
className="toaster group"
|
className="toaster group"
|
||||||
|
position="top-right"
|
||||||
|
richColors
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
classNames: {
|
classNames: {
|
||||||
toast:
|
toast:
|
||||||
|
|
|
||||||
132
surfsense_web/content/docs/code-of-conduct.mdx
Normal file
132
surfsense_web/content/docs/code-of-conduct.mdx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
---
|
||||||
|
title: Code of Conduct
|
||||||
|
description: Community guidelines and expectations for behavior
|
||||||
|
icon: Heart
|
||||||
|
---
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
rohan@surfsense.com.
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.0, available at
|
||||||
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||||
|
enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
https://www.contributor-covenant.org/faq. Translations are available at
|
||||||
|
https://www.contributor-covenant.org/translations.
|
||||||
|
|
@ -88,7 +88,7 @@ After saving, you'll find your OAuth credentials on the integration page:
|
||||||
|
|
||||||
## Running SurfSense with Airtable Connector
|
## Running SurfSense with Airtable Connector
|
||||||
|
|
||||||
Add the Airtable credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
Add the Airtable credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AIRTABLE_CLIENT_ID=your_airtable_client_id
|
AIRTABLE_CLIENT_ID=your_airtable_client_id
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ After creating the app, you'll see your credentials:
|
||||||
|
|
||||||
## Running SurfSense with ClickUp Connector
|
## Running SurfSense with ClickUp Connector
|
||||||
|
|
||||||
Add the ClickUp credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
Add the ClickUp credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
CLICKUP_CLIENT_ID=your_clickup_client_id
|
CLICKUP_CLIENT_ID=your_clickup_client_id
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ Select the **"Granular scopes"** tab and enable:
|
||||||
|
|
||||||
## Running SurfSense with Confluence Connector
|
## Running SurfSense with Confluence Connector
|
||||||
|
|
||||||
Add the Atlassian credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
Add the Atlassian credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ATLASSIAN_CLIENT_ID=your_atlassian_client_id
|
ATLASSIAN_CLIENT_ID=your_atlassian_client_id
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ You'll also see your **Application ID** and **Public Key** on this page.
|
||||||
|
|
||||||
## Running SurfSense with Discord Connector
|
## Running SurfSense with Discord Connector
|
||||||
|
|
||||||
Add the Discord credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
Add the Discord credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DISCORD_CLIENT_ID=your_discord_client_id
|
DISCORD_CLIENT_ID=your_discord_client_id
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
|
||||||
|
|
||||||
## Running SurfSense with Gmail Connector
|
## Running SurfSense with Gmail Connector
|
||||||
|
|
||||||
Add the Google OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
Add the Google OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GOOGLE_OAUTH_CLIENT_ID=your_google_client_id
|
GOOGLE_OAUTH_CLIENT_ID=your_google_client_id
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
|
||||||
|
|
||||||
## Running SurfSense with Google Calendar Connector
|
## Running SurfSense with Google Calendar Connector
|
||||||
|
|
||||||
Add the Google OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
Add the Google OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GOOGLE_OAUTH_CLIENT_ID=your_google_client_id
|
GOOGLE_OAUTH_CLIENT_ID=your_google_client_id
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
|
||||||
|
|
||||||
## Running SurfSense with Google Drive Connector
|
## Running SurfSense with Google Drive Connector
|
||||||
|
|
||||||
Add the Google OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
Add the Google OAuth credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GOOGLE_OAUTH_CLIENT_ID=your_google_client_id
|
GOOGLE_OAUTH_CLIENT_ID=your_google_client_id
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ This guide walks you through setting up an Atlassian OAuth 2.0 (3LO) integration
|
||||||
|
|
||||||
## Running SurfSense with Jira Connector
|
## Running SurfSense with Jira Connector
|
||||||
|
|
||||||
Add the Atlassian credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
Add the Atlassian credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ATLASSIAN_CLIENT_ID=your_atlassian_client_id
|
ATLASSIAN_CLIENT_ID=your_atlassian_client_id
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ After creating the application, you'll see your OAuth credentials:
|
||||||
|
|
||||||
## Running SurfSense with Linear Connector
|
## Running SurfSense with Linear Connector
|
||||||
|
|
||||||
Add the Linear credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
Add the Linear credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
LINEAR_CLIENT_ID=your_linear_client_id
|
LINEAR_CLIENT_ID=your_linear_client_id
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ After registration, you'll be taken to the app's **Overview** page. Here you'll
|
||||||
|
|
||||||
## Running SurfSense with Microsoft Teams Connector
|
## Running SurfSense with Microsoft Teams Connector
|
||||||
|
|
||||||
Add the Microsoft Teams credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
Add the Microsoft Teams credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
TEAMS_CLIENT_ID=your_microsoft_client_id
|
TEAMS_CLIENT_ID=your_microsoft_client_id
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ For additional information:
|
||||||
|
|
||||||
## Running SurfSense with Notion Connector
|
## Running SurfSense with Notion Connector
|
||||||
|
|
||||||
Add the Notion credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
Add the Notion credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
NOTION_OAUTH_CLIENT_ID=your_notion_client_id
|
NOTION_OAUTH_CLIENT_ID=your_notion_client_id
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ Click **"Add an OAuth Scope"** to add each scope.
|
||||||
|
|
||||||
## Running SurfSense with Slack Connector
|
## Running SurfSense with Slack Connector
|
||||||
|
|
||||||
Add the Slack credentials to your `.env` file (created during [Docker installation](/docs/docker-installation)):
|
Add the Slack credentials to your `.env` file (created during [Docker installation](/docs/docker-installation/docker-compose)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SLACK_CLIENT_ID=your_slack_client_id
|
SLACK_CLIENT_ID=your_slack_client_id
|
||||||
|
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
---
|
|
||||||
title: Docker Installation
|
|
||||||
description: Setting up SurfSense using Docker
|
|
||||||
icon: Container
|
|
||||||
---
|
|
||||||
|
|
||||||
This guide explains how to run SurfSense using Docker, with options ranging from a single-command install to a fully manual setup.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Option 1 — Install Script (recommended)
|
|
||||||
|
|
||||||
Downloads the compose files, generates a `SECRET_KEY`, starts all services, and sets up [Watchtower](https://github.com/nicholas-fedor/watchtower) for automatic daily updates.
|
|
||||||
|
|
||||||
**Prerequisites:** [Docker Desktop](https://www.docker.com/products/docker-desktop/) must be installed and running.
|
|
||||||
|
|
||||||
#### For Linux/macOS users:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
#### For Windows users (PowerShell):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates a `./surfsense/` directory with `docker-compose.yml` and `.env`, then runs `docker compose up -d`.
|
|
||||||
|
|
||||||
To skip Watchtower (e.g. in production where you manage updates yourself):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash -s -- --no-watchtower
|
|
||||||
```
|
|
||||||
|
|
||||||
To customise the check interval (default 24h), use `--watchtower-interval=SECONDS`.
|
|
||||||
|
|
||||||
### Option 2 — Manual Docker Compose
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/MODSetter/SurfSense.git
|
|
||||||
cd SurfSense/docker
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env — at minimum set SECRET_KEY
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
After starting, access SurfSense at:
|
|
||||||
|
|
||||||
- **Frontend**: [http://localhost:3000](http://localhost:3000)
|
|
||||||
- **Backend API**: [http://localhost:8000](http://localhost:8000)
|
|
||||||
- **API Docs**: [http://localhost:8000/docs](http://localhost:8000/docs)
|
|
||||||
- **Electric SQL**: [http://localhost:5133](http://localhost:5133)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Updating
|
|
||||||
|
|
||||||
**Option 1 — Watchtower daemon (recommended, auto-updates every 24 h):**
|
|
||||||
|
|
||||||
If you used the install script (Option 1 above), Watchtower is already running. No extra setup needed.
|
|
||||||
|
|
||||||
For manual Docker Compose installs (Option 2), start Watchtower separately:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d --name watchtower \
|
|
||||||
--restart unless-stopped \
|
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
||||||
nickfedor/watchtower \
|
|
||||||
--label-enable \
|
|
||||||
--interval 86400
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option 2 — Watchtower one-time update:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
|
||||||
nickfedor/watchtower --run-once \
|
|
||||||
--label-filter "com.docker.compose.project=surfsense"
|
|
||||||
```
|
|
||||||
|
|
||||||
<Callout type="warn">
|
|
||||||
Use `nickfedor/watchtower`. The original `containrrr/watchtower` is no longer maintained and may fail with newer Docker versions.
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
**Option 3 — Manual:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd surfsense # or SurfSense/docker if you cloned manually
|
|
||||||
docker compose pull && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Database migrations are applied automatically on every startup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
All configuration lives in a single `docker/.env` file (or `surfsense/.env` if you used the install script). Copy `.env.example` to `.env` and edit the values you need.
|
|
||||||
|
|
||||||
### Required
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `SECRET_KEY` | JWT secret key. Generate with: `openssl rand -base64 32`. Auto-generated by the install script. |
|
|
||||||
|
|
||||||
### Core Settings
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `SURFSENSE_VERSION` | Image tag to deploy. Use `latest`, a clean version (e.g. `0.0.14`), or a specific build (e.g. `0.0.14.1`) | `latest` |
|
|
||||||
| `AUTH_TYPE` | Authentication method: `LOCAL` (email/password) or `GOOGLE` (OAuth) | `LOCAL` |
|
|
||||||
| `ETL_SERVICE` | Document parsing: `DOCLING` (local), `UNSTRUCTURED`, or `LLAMACLOUD` | `DOCLING` |
|
|
||||||
| `EMBEDDING_MODEL` | Embedding model for vector search | `sentence-transformers/all-MiniLM-L6-v2` |
|
|
||||||
| `TTS_SERVICE` | Text-to-speech provider for podcasts | `local/kokoro` |
|
|
||||||
| `STT_SERVICE` | Speech-to-text provider for audio files | `local/base` |
|
|
||||||
| `REGISTRATION_ENABLED` | Allow new user registrations | `TRUE` |
|
|
||||||
|
|
||||||
### Ports
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `FRONTEND_PORT` | Frontend service port | `3000` |
|
|
||||||
| `BACKEND_PORT` | Backend API service port | `8000` |
|
|
||||||
| `ELECTRIC_PORT` | Electric SQL service port | `5133` |
|
|
||||||
|
|
||||||
### Custom Domain / Reverse Proxy
|
|
||||||
|
|
||||||
Only set these if serving SurfSense on a real domain via a reverse proxy (Caddy, Nginx, Cloudflare Tunnel, etc.). Leave commented out for standard localhost deployments.
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `NEXT_FRONTEND_URL` | Public frontend URL (e.g. `https://app.yourdomain.com`) |
|
|
||||||
| `BACKEND_URL` | Public backend URL for OAuth callbacks (e.g. `https://api.yourdomain.com`) |
|
|
||||||
| `NEXT_PUBLIC_FASTAPI_BACKEND_URL` | Backend URL used by the frontend (e.g. `https://api.yourdomain.com`) |
|
|
||||||
| `NEXT_PUBLIC_ELECTRIC_URL` | Electric SQL URL used by the frontend (e.g. `https://electric.yourdomain.com`) |
|
|
||||||
|
|
||||||
### Database
|
|
||||||
|
|
||||||
Defaults work out of the box. Change for security in production.
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `DB_USER` | PostgreSQL username | `surfsense` |
|
|
||||||
| `DB_PASSWORD` | PostgreSQL password | `surfsense` |
|
|
||||||
| `DB_NAME` | PostgreSQL database name | `surfsense` |
|
|
||||||
| `DB_HOST` | PostgreSQL host | `db` |
|
|
||||||
| `DB_PORT` | PostgreSQL port | `5432` |
|
|
||||||
| `DB_SSLMODE` | SSL mode: `disable`, `require`, `verify-ca`, `verify-full` | `disable` |
|
|
||||||
| `DATABASE_URL` | Full connection URL override. Use for managed databases (RDS, Supabase, etc.) | *(built from above)* |
|
|
||||||
|
|
||||||
### Electric SQL
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `ELECTRIC_DB_USER` | Replication user for Electric SQL | `electric` |
|
|
||||||
| `ELECTRIC_DB_PASSWORD` | Replication password for Electric SQL | `electric_password` |
|
|
||||||
| `ELECTRIC_DATABASE_URL` | Full connection URL override for Electric. Set to `host.docker.internal` when pointing at a local Postgres instance | *(built from above)* |
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `GOOGLE_OAUTH_CLIENT_ID` | Google OAuth client ID (required if `AUTH_TYPE=GOOGLE`) |
|
|
||||||
| `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth client secret (required if `AUTH_TYPE=GOOGLE`) |
|
|
||||||
|
|
||||||
Create credentials at the [Google Cloud Console](https://console.cloud.google.com/apis/credentials).
|
|
||||||
|
|
||||||
### External API Keys
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `FIRECRAWL_API_KEY` | Firecrawl API key for web crawling |
|
|
||||||
| `UNSTRUCTURED_API_KEY` | Unstructured.io API key (required if `ETL_SERVICE=UNSTRUCTURED`) |
|
|
||||||
| `LLAMA_CLOUD_API_KEY` | LlamaCloud API key (required if `ETL_SERVICE=LLAMACLOUD`) |
|
|
||||||
|
|
||||||
### Connector OAuth Keys
|
|
||||||
|
|
||||||
Uncomment the connectors you want to use. Redirect URIs follow the pattern `http://localhost:8000/api/v1/auth/<connector>/connector/callback`.
|
|
||||||
|
|
||||||
| Connector | Variables |
|
|
||||||
|-----------|-----------|
|
|
||||||
| Google Drive / Gmail / Calendar | `GOOGLE_DRIVE_REDIRECT_URI`, `GOOGLE_GMAIL_REDIRECT_URI`, `GOOGLE_CALENDAR_REDIRECT_URI` |
|
|
||||||
| Notion | `NOTION_CLIENT_ID`, `NOTION_CLIENT_SECRET`, `NOTION_REDIRECT_URI` |
|
|
||||||
| Slack | `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, `SLACK_REDIRECT_URI` |
|
|
||||||
| Discord | `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN`, `DISCORD_REDIRECT_URI` |
|
|
||||||
| Jira & Confluence | `ATLASSIAN_CLIENT_ID`, `ATLASSIAN_CLIENT_SECRET`, `JIRA_REDIRECT_URI`, `CONFLUENCE_REDIRECT_URI` |
|
|
||||||
| Linear | `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET`, `LINEAR_REDIRECT_URI` |
|
|
||||||
| ClickUp | `CLICKUP_CLIENT_ID`, `CLICKUP_CLIENT_SECRET`, `CLICKUP_REDIRECT_URI` |
|
|
||||||
| Airtable | `AIRTABLE_CLIENT_ID`, `AIRTABLE_CLIENT_SECRET`, `AIRTABLE_REDIRECT_URI` |
|
|
||||||
| Microsoft Teams | `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, `TEAMS_REDIRECT_URI` |
|
|
||||||
|
|
||||||
For Airtable, create an OAuth integration at the [Airtable Developer Hub](https://airtable.com/create/oauth).
|
|
||||||
|
|
||||||
### Observability (optional)
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `LANGSMITH_TRACING` | Enable LangSmith tracing (`true` / `false`) |
|
|
||||||
| `LANGSMITH_ENDPOINT` | LangSmith API endpoint |
|
|
||||||
| `LANGSMITH_API_KEY` | LangSmith API key |
|
|
||||||
| `LANGSMITH_PROJECT` | LangSmith project name |
|
|
||||||
|
|
||||||
### Advanced (optional)
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `SCHEDULE_CHECKER_INTERVAL` | How often to check for scheduled connector tasks (e.g. `5m`, `1h`) | `5m` |
|
|
||||||
| `RERANKERS_ENABLED` | Enable document reranking for improved search | `FALSE` |
|
|
||||||
| `RERANKERS_MODEL_NAME` | Reranker model name (e.g. `ms-marco-MiniLM-L-12-v2`) | |
|
|
||||||
| `RERANKERS_MODEL_TYPE` | Reranker model type (e.g. `flashrank`) | |
|
|
||||||
| `PAGES_LIMIT` | Max pages per user for ETL services | unlimited |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker Services
|
|
||||||
|
|
||||||
| Service | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `db` | PostgreSQL with pgvector extension |
|
|
||||||
| `redis` | Message broker for Celery |
|
|
||||||
| `backend` | FastAPI application server |
|
|
||||||
| `celery_worker` | Background task processing (document indexing, etc.) |
|
|
||||||
| `celery_beat` | Periodic task scheduler (connector sync) |
|
|
||||||
| `electric` | Electric SQL — real-time sync for the frontend |
|
|
||||||
| `frontend` | Next.js web application |
|
|
||||||
|
|
||||||
All services start automatically with `docker compose up -d`.
|
|
||||||
|
|
||||||
The backend includes a health check — dependent services (workers, frontend) wait until the API is fully ready before starting. You can monitor startup progress with `docker compose ps` (look for `(health: starting)` → `(healthy)`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development Compose File
|
|
||||||
|
|
||||||
If you're contributing to SurfSense and want to build from source, use `docker-compose.dev.yml` instead:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd SurfSense/docker
|
|
||||||
docker compose -f docker-compose.dev.yml up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
This file builds the backend and frontend from your local source code (instead of pulling prebuilt images) and includes pgAdmin for database inspection at [http://localhost:5050](http://localhost:5050). Use the production `docker-compose.yml` for all other cases.
|
|
||||||
|
|
||||||
The following `.env` variables are **only used by the dev compose file** (they have no effect on the production `docker-compose.yml`):
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `PGADMIN_PORT` | pgAdmin web UI port | `5050` |
|
|
||||||
| `PGADMIN_DEFAULT_EMAIL` | pgAdmin login email | `admin@surfsense.com` |
|
|
||||||
| `PGADMIN_DEFAULT_PASSWORD` | pgAdmin login password | `surfsense` |
|
|
||||||
| `REDIS_PORT` | Exposed Redis port (internal-only in prod) | `6379` |
|
|
||||||
| `NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE` | Frontend build arg for auth type | `LOCAL` |
|
|
||||||
| `NEXT_PUBLIC_ETL_SERVICE` | Frontend build arg for ETL service | `DOCLING` |
|
|
||||||
| `NEXT_PUBLIC_DEPLOYMENT_MODE` | Frontend build arg for deployment mode | `self-hosted` |
|
|
||||||
| `NEXT_PUBLIC_ELECTRIC_AUTH_MODE` | Frontend build arg for Electric auth | `insecure` |
|
|
||||||
|
|
||||||
In the production compose file, the `NEXT_PUBLIC_*` frontend variables are automatically derived from `AUTH_TYPE`, `ETL_SERVICE`, and the port settings. In the dev compose file, they are passed as build args since the frontend is built from source.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migrating from the All-in-One Container
|
|
||||||
|
|
||||||
<Callout type="warn">
|
|
||||||
If you were previously using `docker-compose.quickstart.yml` (the legacy all-in-one `surfsense` container), your data lives in a `surfsense-data` volume and requires a **one-time migration** before switching to the current setup. PostgreSQL has been upgraded from version 14 to 17, so a simple volume swap will not work.
|
|
||||||
|
|
||||||
See the full step-by-step guide: [Migrate from the All-in-One Container](/docs/how-to/migrate-from-allinone).
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Useful Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# View logs (all services)
|
|
||||||
docker compose logs -f
|
|
||||||
|
|
||||||
# View logs for a specific service
|
|
||||||
docker compose logs -f backend
|
|
||||||
docker compose logs -f electric
|
|
||||||
|
|
||||||
# Stop all services
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
# Restart a specific service
|
|
||||||
docker compose restart backend
|
|
||||||
|
|
||||||
# Stop and remove all containers + volumes (destructive!)
|
|
||||||
docker compose down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
- **Ports already in use** — Change the relevant `*_PORT` variable in `.env` and restart.
|
|
||||||
- **Permission errors on Linux** — You may need to prefix `docker` commands with `sudo`.
|
|
||||||
- **Electric SQL not connecting** — Check `docker compose logs electric`. If it shows `domain does not exist: db`, ensure `ELECTRIC_DATABASE_URL` is not set to a stale value in `.env`.
|
|
||||||
- **Real-time updates not working in browser** — Open DevTools → Console and look for `[Electric]` errors. Check that `NEXT_PUBLIC_ELECTRIC_URL` matches the running Electric SQL address.
|
|
||||||
- **Line ending issues on Windows** — Run `git config --global core.autocrlf true` before cloning.
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
---
|
||||||
|
title: Docker Compose Development
|
||||||
|
description: Building SurfSense from source using docker-compose.dev.yml
|
||||||
|
---
|
||||||
|
|
||||||
|
If you're contributing to SurfSense and want to build from source, use `docker-compose.dev.yml` instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd SurfSense/docker
|
||||||
|
docker compose -f docker-compose.dev.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
This file builds the backend and frontend from your local source code (instead of pulling prebuilt images) and includes pgAdmin for database inspection at [http://localhost:5050](http://localhost:5050). Use the production `docker-compose.yml` for all other cases.
|
||||||
|
|
||||||
|
## Dev-Only Environment Variables
|
||||||
|
|
||||||
|
The following `.env` variables are **only used by the dev compose file** (they have no effect on the production `docker-compose.yml`):
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `PGADMIN_PORT` | pgAdmin web UI port | `5050` |
|
||||||
|
| `PGADMIN_DEFAULT_EMAIL` | pgAdmin login email | `admin@surfsense.com` |
|
||||||
|
| `PGADMIN_DEFAULT_PASSWORD` | pgAdmin login password | `surfsense` |
|
||||||
|
| `REDIS_PORT` | Exposed Redis port (internal-only in prod) | `6379` |
|
||||||
|
| `NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE` | Frontend build arg for auth type | `LOCAL` |
|
||||||
|
| `NEXT_PUBLIC_ETL_SERVICE` | Frontend build arg for ETL service | `DOCLING` |
|
||||||
|
| `NEXT_PUBLIC_DEPLOYMENT_MODE` | Frontend build arg for deployment mode | `self-hosted` |
|
||||||
|
| `NEXT_PUBLIC_ELECTRIC_AUTH_MODE` | Frontend build arg for Electric auth | `insecure` |
|
||||||
|
|
||||||
|
In the production compose file, the `NEXT_PUBLIC_*` frontend variables are automatically derived from `AUTH_TYPE`, `ETL_SERVICE`, and the port settings. In the dev compose file, they are passed as build args since the frontend is built from source.
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
---
|
||||||
|
title: Docker Compose
|
||||||
|
description: Manual Docker Compose setup for SurfSense
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/MODSetter/SurfSense.git
|
||||||
|
cd SurfSense/docker
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env, at minimum set SECRET_KEY
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
After starting, access SurfSense at:
|
||||||
|
|
||||||
|
- **Frontend**: [http://localhost:3929](http://localhost:3929)
|
||||||
|
- **Backend API**: [http://localhost:8929](http://localhost:8929)
|
||||||
|
- **API Docs**: [http://localhost:8929/docs](http://localhost:8929/docs)
|
||||||
|
- **Electric SQL**: [http://localhost:5929](http://localhost:5929)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration lives in a single `docker/.env` file (or `surfsense/.env` if you used the install script). Copy `.env.example` to `.env` and edit the values you need.
|
||||||
|
|
||||||
|
### Required
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `SECRET_KEY` | JWT secret key. Generate with: `openssl rand -base64 32`. Auto-generated by the install script. |
|
||||||
|
|
||||||
|
### Core Settings
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `SURFSENSE_VERSION` | Image tag to deploy. Use `latest`, a clean version (e.g. `0.0.14`), or a specific build (e.g. `0.0.14.1`) | `latest` |
|
||||||
|
| `AUTH_TYPE` | Authentication method: `LOCAL` (email/password) or `GOOGLE` (OAuth) | `LOCAL` |
|
||||||
|
| `ETL_SERVICE` | Document parsing: `DOCLING` (local), `UNSTRUCTURED`, or `LLAMACLOUD` | `DOCLING` |
|
||||||
|
| `EMBEDDING_MODEL` | Embedding model for vector search | `sentence-transformers/all-MiniLM-L6-v2` |
|
||||||
|
| `TTS_SERVICE` | Text-to-speech provider for podcasts | `local/kokoro` |
|
||||||
|
| `STT_SERVICE` | Speech-to-text provider for audio files | `local/base` |
|
||||||
|
| `REGISTRATION_ENABLED` | Allow new user registrations | `TRUE` |
|
||||||
|
|
||||||
|
### Ports
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `FRONTEND_PORT` | Frontend service port | `3929` |
|
||||||
|
| `BACKEND_PORT` | Backend API service port | `8929` |
|
||||||
|
| `ELECTRIC_PORT` | Electric SQL service port | `5929` |
|
||||||
|
|
||||||
|
### Custom Domain / Reverse Proxy
|
||||||
|
|
||||||
|
Only set these if serving SurfSense on a real domain via a reverse proxy (Caddy, Nginx, Cloudflare Tunnel, etc.). Leave commented out for standard localhost deployments.
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `NEXT_FRONTEND_URL` | Public frontend URL (e.g. `https://app.yourdomain.com`) |
|
||||||
|
| `BACKEND_URL` | Public backend URL for OAuth callbacks (e.g. `https://api.yourdomain.com`) |
|
||||||
|
| `NEXT_PUBLIC_FASTAPI_BACKEND_URL` | Backend URL used by the frontend (e.g. `https://api.yourdomain.com`) |
|
||||||
|
| `NEXT_PUBLIC_ELECTRIC_URL` | Electric SQL URL used by the frontend (e.g. `https://electric.yourdomain.com`) |
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
Defaults work out of the box. Change for security in production.
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `DB_USER` | PostgreSQL username | `surfsense` |
|
||||||
|
| `DB_PASSWORD` | PostgreSQL password | `surfsense` |
|
||||||
|
| `DB_NAME` | PostgreSQL database name | `surfsense` |
|
||||||
|
| `DB_HOST` | PostgreSQL host | `db` |
|
||||||
|
| `DB_PORT` | PostgreSQL port | `5432` |
|
||||||
|
| `DB_SSLMODE` | SSL mode: `disable`, `require`, `verify-ca`, `verify-full` | `disable` |
|
||||||
|
| `DATABASE_URL` | Full connection URL override. Use for managed databases (RDS, Supabase, etc.) | *(built from above)* |
|
||||||
|
|
||||||
|
### Electric SQL
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `ELECTRIC_DB_USER` | Replication user for Electric SQL | `electric` |
|
||||||
|
| `ELECTRIC_DB_PASSWORD` | Replication password for Electric SQL | `electric_password` |
|
||||||
|
| `ELECTRIC_DATABASE_URL` | Full connection URL override for Electric. Set to `host.docker.internal` when pointing at a local Postgres instance | *(built from above)* |
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `GOOGLE_OAUTH_CLIENT_ID` | Google OAuth client ID (required if `AUTH_TYPE=GOOGLE`) |
|
||||||
|
| `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth client secret (required if `AUTH_TYPE=GOOGLE`) |
|
||||||
|
|
||||||
|
Create credentials at the [Google Cloud Console](https://console.cloud.google.com/apis/credentials).
|
||||||
|
|
||||||
|
### External API Keys
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `FIRECRAWL_API_KEY` | [Firecrawl](https://www.firecrawl.dev/) API key for web crawling |
|
||||||
|
| `UNSTRUCTURED_API_KEY` | [Unstructured.io](https://unstructured.io/) API key (required if `ETL_SERVICE=UNSTRUCTURED`) |
|
||||||
|
| `LLAMA_CLOUD_API_KEY` | [LlamaCloud](https://cloud.llamaindex.ai/) API key (required if `ETL_SERVICE=LLAMACLOUD`) |
|
||||||
|
|
||||||
|
### Connector OAuth Keys
|
||||||
|
|
||||||
|
Uncomment the connectors you want to use. Redirect URIs follow the pattern `http://localhost:8000/api/v1/auth/<connector>/connector/callback`.
|
||||||
|
|
||||||
|
| Connector | Variables |
|
||||||
|
|-----------|-----------|
|
||||||
|
| Google Drive / Gmail / Calendar | `GOOGLE_DRIVE_REDIRECT_URI`, `GOOGLE_GMAIL_REDIRECT_URI`, `GOOGLE_CALENDAR_REDIRECT_URI` |
|
||||||
|
| Notion | `NOTION_CLIENT_ID`, `NOTION_CLIENT_SECRET`, `NOTION_REDIRECT_URI` |
|
||||||
|
| Slack | `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, `SLACK_REDIRECT_URI` |
|
||||||
|
| Discord | `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_BOT_TOKEN`, `DISCORD_REDIRECT_URI` |
|
||||||
|
| Jira & Confluence | `ATLASSIAN_CLIENT_ID`, `ATLASSIAN_CLIENT_SECRET`, `JIRA_REDIRECT_URI`, `CONFLUENCE_REDIRECT_URI` |
|
||||||
|
| Linear | `LINEAR_CLIENT_ID`, `LINEAR_CLIENT_SECRET`, `LINEAR_REDIRECT_URI` |
|
||||||
|
| ClickUp | `CLICKUP_CLIENT_ID`, `CLICKUP_CLIENT_SECRET`, `CLICKUP_REDIRECT_URI` |
|
||||||
|
| Airtable | `AIRTABLE_CLIENT_ID`, `AIRTABLE_CLIENT_SECRET`, `AIRTABLE_REDIRECT_URI` |
|
||||||
|
| Microsoft Teams | `TEAMS_CLIENT_ID`, `TEAMS_CLIENT_SECRET`, `TEAMS_REDIRECT_URI` |
|
||||||
|
|
||||||
|
### Observability (optional)
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `LANGSMITH_TRACING` | Enable LangSmith tracing (`true` / `false`) |
|
||||||
|
| `LANGSMITH_ENDPOINT` | LangSmith API endpoint |
|
||||||
|
| `LANGSMITH_API_KEY` | LangSmith API key |
|
||||||
|
| `LANGSMITH_PROJECT` | LangSmith project name |
|
||||||
|
|
||||||
|
### Advanced (optional)
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `SCHEDULE_CHECKER_INTERVAL` | How often to check for scheduled connector tasks (e.g. `5m`, `1h`) | `5m` |
|
||||||
|
| `RERANKERS_ENABLED` | Enable document reranking for improved search | `FALSE` |
|
||||||
|
| `RERANKERS_MODEL_NAME` | Reranker model name (e.g. `ms-marco-MiniLM-L-12-v2`) | |
|
||||||
|
| `RERANKERS_MODEL_TYPE` | Reranker model type (e.g. `flashrank`) | |
|
||||||
|
| `PAGES_LIMIT` | Max pages per user for ETL services | unlimited |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Services
|
||||||
|
|
||||||
|
| Service | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `db` | PostgreSQL with pgvector extension |
|
||||||
|
| `redis` | Message broker for Celery |
|
||||||
|
| `backend` | FastAPI application server |
|
||||||
|
| `celery_worker` | Background task processing (document indexing, etc.) |
|
||||||
|
| `celery_beat` | Periodic task scheduler (connector sync) |
|
||||||
|
| `electric` | Electric SQL (real-time sync for the frontend) |
|
||||||
|
| `frontend` | Next.js web application |
|
||||||
|
|
||||||
|
All services start automatically with `docker compose up -d`.
|
||||||
|
|
||||||
|
The backend includes a health check. Dependent services (workers, frontend) wait until the API is fully ready before starting. You can monitor startup progress with `docker compose ps` (look for `(health: starting)` → `(healthy)`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View logs (all services)
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# View logs for a specific service
|
||||||
|
docker compose logs -f backend
|
||||||
|
docker compose logs -f electric
|
||||||
|
|
||||||
|
# Stop all services
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Restart a specific service
|
||||||
|
docker compose restart backend
|
||||||
|
|
||||||
|
# Stop and remove all containers + volumes (destructive!)
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Ports already in use**: Change the relevant `*_PORT` variable in `.env` and restart.
|
||||||
|
- **Permission errors on Linux**: You may need to prefix `docker` commands with `sudo`.
|
||||||
|
- **Electric SQL not connecting**: Check `docker compose logs electric`. If it shows `domain does not exist: db`, ensure `ELECTRIC_DATABASE_URL` is not set to a stale value in `.env`.
|
||||||
|
- **Real-time updates not working in browser**: Open DevTools → Console and look for `[Electric]` errors. Check that `NEXT_PUBLIC_ELECTRIC_URL` matches the running Electric SQL address.
|
||||||
|
- **Line ending issues on Windows**: Run `git config --global core.autocrlf true` before cloning.
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
---
|
||||||
|
title: One-Line Install Script
|
||||||
|
description: One-command installation of SurfSense using Docker
|
||||||
|
---
|
||||||
|
|
||||||
|
Downloads the compose files, generates a `SECRET_KEY`, starts all services, and sets up [Watchtower](https://github.com/nicholas-fedor/watchtower) for automatic daily updates.
|
||||||
|
|
||||||
|
**Prerequisites:** [Docker Desktop](https://www.docker.com/products/docker-desktop/) must be installed and running.
|
||||||
|
|
||||||
|
### For Linux/macOS users:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Windows users (PowerShell):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a `./surfsense/` directory with `docker-compose.yml` and `.env`, then runs `docker compose up -d`.
|
||||||
|
|
||||||
|
To skip Watchtower (e.g. in production where you manage updates yourself):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash -s -- --no-watchtower
|
||||||
|
```
|
||||||
|
|
||||||
|
To customise the check interval (default 24h), use `--watchtower-interval=SECONDS`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Access SurfSense
|
||||||
|
|
||||||
|
After starting, access SurfSense at:
|
||||||
|
|
||||||
|
- **Frontend**: [http://localhost:3929](http://localhost:3929)
|
||||||
|
- **Backend API**: [http://localhost:8929](http://localhost:8929)
|
||||||
|
- **API Docs**: [http://localhost:8929/docs](http://localhost:8929/docs)
|
||||||
|
- **Electric SQL**: [http://localhost:5929](http://localhost:5929)
|
||||||
6
surfsense_web/content/docs/docker-installation/meta.json
Normal file
6
surfsense_web/content/docs/docker-installation/meta.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"title": "Docker Installation",
|
||||||
|
"pages": ["install-script", "docker-compose", "updating", "dev-compose", "migrate-from-allinone"],
|
||||||
|
"icon": "Container",
|
||||||
|
"defaultOpen": false
|
||||||
|
}
|
||||||
|
|
@ -81,87 +81,6 @@ bash migrate-database.sh --db-user myuser --db-password mypass --db-name mydb
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Option C — Manual steps
|
|
||||||
|
|
||||||
For users who prefer full control or whose platform doesn't support bash scripts (e.g. Windows without WSL2).
|
|
||||||
|
|
||||||
### Step 1 — Stop the old all-in-one container
|
|
||||||
|
|
||||||
Before mounting the `surfsense-data` volume into a new container, stop the existing one to prevent two PostgreSQL processes from writing to the same data directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker stop surfsense 2>/dev/null || true
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2 — Start a temporary PostgreSQL 14 container
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d --name surfsense-pg14-temp \
|
|
||||||
-v surfsense-data:/data \
|
|
||||||
-e PGDATA=/data/postgres \
|
|
||||||
-e POSTGRES_USER=surfsense \
|
|
||||||
-e POSTGRES_PASSWORD=surfsense \
|
|
||||||
-e POSTGRES_DB=surfsense \
|
|
||||||
pgvector/pgvector:pg14
|
|
||||||
```
|
|
||||||
|
|
||||||
Wait ~10 seconds, then confirm it is healthy:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec surfsense-pg14-temp pg_isready -U surfsense
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3 — Dump the database
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -e PGPASSWORD=surfsense surfsense-pg14-temp \
|
|
||||||
pg_dump -U surfsense surfsense > surfsense_backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4 — Recover your SECRET\_KEY
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --rm -v surfsense-data:/data alpine cat /data/.secret_key
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5 — Set up the new stack
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p surfsense/scripts
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/docker-compose.yml -o surfsense/docker-compose.yml
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/.env.example -o surfsense/.env.example
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/postgresql.conf -o surfsense/postgresql.conf
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/init-electric-user.sh -o surfsense/scripts/init-electric-user.sh
|
|
||||||
chmod +x surfsense/scripts/init-electric-user.sh
|
|
||||||
cp surfsense/.env.example surfsense/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
Set `SECRET_KEY` in `surfsense/.env` to the value from Step 4.
|
|
||||||
|
|
||||||
### Step 6 — Start PostgreSQL 17 and restore
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd surfsense
|
|
||||||
docker compose up -d db
|
|
||||||
docker compose exec db pg_isready -U surfsense # wait until ready
|
|
||||||
docker compose exec -T db psql -U surfsense -d surfsense < ../surfsense_backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 7 — Start all services
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 8 — Clean up
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker stop surfsense-pg14-temp && docker rm surfsense-pg14-temp
|
|
||||||
docker volume rm surfsense-data # only after verifying migration succeeded
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### `install.sh` runs normally with a blank database (no migration happened)
|
### `install.sh` runs normally with a blank database (no migration happened)
|
||||||
50
surfsense_web/content/docs/docker-installation/updating.mdx
Normal file
50
surfsense_web/content/docs/docker-installation/updating.mdx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
---
|
||||||
|
title: Updating
|
||||||
|
description: How to update your SurfSense Docker deployment
|
||||||
|
---
|
||||||
|
|
||||||
|
## Watchtower Daemon (recommended)
|
||||||
|
|
||||||
|
Auto-updates every 24 hours. If you used the [install script](/docs/docker-installation/install-script), Watchtower is already running. No extra setup needed.
|
||||||
|
|
||||||
|
For [manual Docker Compose](/docs/docker-installation/docker-compose) installs, start Watchtower separately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name watchtower \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
nickfedor/watchtower \
|
||||||
|
--label-enable \
|
||||||
|
--interval 86400
|
||||||
|
```
|
||||||
|
|
||||||
|
## Watchtower One-Time Update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
nickfedor/watchtower --run-once \
|
||||||
|
--label-filter "com.docker.compose.project=surfsense"
|
||||||
|
```
|
||||||
|
|
||||||
|
<Callout type="warn">
|
||||||
|
Use `nickfedor/watchtower`. The original `containrrr/watchtower` is no longer maintained and may fail with newer Docker versions.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
## Manual Update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd surfsense # or SurfSense/docker if you cloned manually
|
||||||
|
docker compose pull && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Database migrations are applied automatically on every startup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migrating from the All-in-One Container
|
||||||
|
|
||||||
|
<Callout type="warn">
|
||||||
|
If you were previously using `docker-compose.quickstart.yml` (the legacy all-in-one `surfsense` container), your data lives in a `surfsense-data` volume and requires a **one-time migration** before switching to the current setup. PostgreSQL has been upgraded from version 14 to 17, so a simple volume swap will not work.
|
||||||
|
|
||||||
|
See the full step-by-step guide: [Migrate from the All-in-One Container](/docs/docker-installation/migrate-from-allinone).
|
||||||
|
</Callout>
|
||||||
|
|
@ -5,7 +5,7 @@ description: Setting up Electric SQL for real-time data synchronization in SurfS
|
||||||
|
|
||||||
[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for inbox items, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL.
|
[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for inbox items, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL.
|
||||||
|
|
||||||
## What Does Electric SQL Do?
|
## What does Electric SQL do?
|
||||||
|
|
||||||
When you index documents or receive inbox updates, Electric SQL pushes updates to your browser in real-time. The data flows like this:
|
When you index documents or receive inbox updates, Electric SQL pushes updates to your browser in real-time. The data flows like this:
|
||||||
|
|
||||||
|
|
@ -23,45 +23,24 @@ This means:
|
||||||
|
|
||||||
## Docker Setup
|
## Docker Setup
|
||||||
|
|
||||||
The `docker-compose.yml` includes the Electric SQL service. It is pre-configured to connect to the Docker-managed `db` container out of the box.
|
- The `docker-compose.yml` includes the Electric SQL service, pre-configured to connect to the Docker-managed `db` container.
|
||||||
|
- No additional configuration is required. Electric SQL works with the Docker PostgreSQL instance out of the box.
|
||||||
|
|
||||||
```bash
|
## Manual Setup (Development Only)
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
The Electric SQL service configuration in `docker-compose.yml`:
|
This section is intended for local development environments. Follow the steps below based on your PostgreSQL setup.
|
||||||
|
|
||||||
```yaml
|
|
||||||
electric:
|
|
||||||
image: electricsql/electric:1.4.6
|
|
||||||
ports:
|
|
||||||
- "${ELECTRIC_PORT:-5133}:3000"
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: ${ELECTRIC_DATABASE_URL:-postgresql://${ELECTRIC_DB_USER:-electric}:${ELECTRIC_DB_PASSWORD:-electric_password}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}?sslmode=${DB_SSLMODE:-disable}}
|
|
||||||
ELECTRIC_INSECURE: "true"
|
|
||||||
ELECTRIC_WRITE_TO_PG_MODE: direct
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
```
|
|
||||||
|
|
||||||
No additional configuration is required — Electric SQL is pre-configured to work with the Docker PostgreSQL instance.
|
|
||||||
|
|
||||||
## Manual Setup
|
|
||||||
|
|
||||||
Follow the steps below based on your PostgreSQL setup.
|
|
||||||
|
|
||||||
### Step 1: Configure Environment Variables
|
### Step 1: Configure Environment Variables
|
||||||
|
|
||||||
Ensure your environment files are configured. If you haven't set up SurfSense yet, follow the [Manual Installation Guide](/docs/manual-installation) first.
|
Ensure your environment files are configured. If you haven't set up SurfSense yet, follow the [Manual Installation Guide](/docs/manual-installation) first.
|
||||||
|
|
||||||
For Electric SQL, verify these variables are set in `docker/.env`:
|
For Electric SQL, verify these variables are set:
|
||||||
|
|
||||||
|
**Backend (`surfsense_backend/.env`):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ELECTRIC_PORT=5133
|
|
||||||
ELECTRIC_DB_USER=electric
|
ELECTRIC_DB_USER=electric
|
||||||
ELECTRIC_DB_PASSWORD=electric_password
|
ELECTRIC_DB_PASSWORD=electric_password
|
||||||
NEXT_PUBLIC_ELECTRIC_URL=http://localhost:5133
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Frontend (`surfsense_web/.env`):**
|
**Frontend (`surfsense_web/.env`):**
|
||||||
|
|
@ -71,17 +50,19 @@ NEXT_PUBLIC_ELECTRIC_URL=http://localhost:5133
|
||||||
NEXT_PUBLIC_ELECTRIC_AUTH_MODE=insecure
|
NEXT_PUBLIC_ELECTRIC_AUTH_MODE=insecure
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Next, choose the option that matches your PostgreSQL setup:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Option A: Using Docker PostgreSQL
|
### Option A: Using Docker PostgreSQL
|
||||||
|
|
||||||
If you're using the Docker-managed PostgreSQL instance, no extra configuration is needed. Just start the services:
|
If you're using the Docker-managed PostgreSQL instance, no extra configuration is needed. Just start the services using the development compose file (which exposes the PostgreSQL port to your host machine):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d db electric
|
docker compose -f docker-compose.dev.yml up -d db electric
|
||||||
```
|
```
|
||||||
|
|
||||||
Then run the database migration and start the backend:
|
Then run the database migration, start the backend, and launch the frontend:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd surfsense_backend
|
cd surfsense_backend
|
||||||
|
|
@ -89,6 +70,13 @@ uv run alembic upgrade head
|
||||||
uv run main.py
|
uv run main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In a separate terminal, start the frontend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd surfsense_web
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
Electric SQL is now configured and connected to your Docker PostgreSQL database.
|
Electric SQL is now configured and connected to your Docker PostgreSQL database.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -148,7 +136,7 @@ ELECTRIC_DATABASE_URL=postgresql://electric:electric_password@host.docker.intern
|
||||||
**4. Start Electric SQL only (skip the Docker `db` container):**
|
**4. Start Electric SQL only (skip the Docker `db` container):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --no-deps electric
|
docker compose -f docker-compose.dev.yml up -d --no-deps electric
|
||||||
```
|
```
|
||||||
|
|
||||||
The `--no-deps` flag starts only the `electric` service without starting the Docker-managed `db` container.
|
The `--no-deps` flag starts only the `electric` service without starting the Docker-managed `db` container.
|
||||||
|
|
@ -161,18 +149,32 @@ uv run alembic upgrade head
|
||||||
uv run main.py
|
uv run main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In a separate terminal, start the frontend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd surfsense_web
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
Electric SQL is now configured and connected to your local PostgreSQL database.
|
Electric SQL is now configured and connected to your local PostgreSQL database.
|
||||||
|
|
||||||
## Environment Variables Reference
|
## Environment Variables Reference
|
||||||
|
|
||||||
|
**Required for manual setup:**
|
||||||
|
|
||||||
| Variable | Location | Description | Default |
|
| Variable | Location | Description | Default |
|
||||||
|----------|----------|-------------|---------|
|
|----------|----------|-------------|---------|
|
||||||
| `ELECTRIC_PORT` | `docker/.env` | Port to expose Electric SQL | `5133` |
|
| `ELECTRIC_DB_USER` | `surfsense_backend/.env` | Database user for Electric replication | `electric` |
|
||||||
| `ELECTRIC_DB_USER` | `docker/.env` | Database user for Electric replication | `electric` |
|
| `ELECTRIC_DB_PASSWORD` | `surfsense_backend/.env` | Database password for Electric replication | `electric_password` |
|
||||||
| `ELECTRIC_DB_PASSWORD` | `docker/.env` | Database password for Electric replication | `electric_password` |
|
| `NEXT_PUBLIC_ELECTRIC_URL` | `surfsense_web/.env` | Electric SQL server URL (PGlite connects to this) | `http://localhost:5133` |
|
||||||
| `ELECTRIC_DATABASE_URL` | `docker/.env` | Full connection URL override for Electric. Set to use `host.docker.internal` when pointing at a local Postgres instance | *(built from above defaults)* |
|
| `NEXT_PUBLIC_ELECTRIC_AUTH_MODE` | `surfsense_web/.env` | Authentication mode (`insecure` for dev, `secure` for production) | `insecure` |
|
||||||
| `NEXT_PUBLIC_ELECTRIC_URL` | Frontend `.env` | Electric SQL server URL (PGlite connects to this) | `http://localhost:5133` |
|
|
||||||
| `NEXT_PUBLIC_ELECTRIC_AUTH_MODE` | Frontend `.env` | Authentication mode (`insecure` for dev, `secure` for production) | `insecure` |
|
**Optional / Docker-only:**
|
||||||
|
|
||||||
|
| Variable | Location | Description | Default |
|
||||||
|
|----------|----------|-------------|---------|
|
||||||
|
| `ELECTRIC_PORT` | `docker/.env` | Port to expose Electric SQL on the host | `5133` (dev), `5929` (production) |
|
||||||
|
| `ELECTRIC_DATABASE_URL` | `docker/.env` | Full connection URL override for Electric. Only needed for Option B (local Postgres via `host.docker.internal`) | *(built from above defaults)* |
|
||||||
|
|
||||||
## Verify Setup
|
## Verify Setup
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"title": "How to",
|
"title": "How to",
|
||||||
"pages": ["electric-sql", "realtime-collaboration", "migrate-from-allinone"],
|
"pages": ["electric-sql", "realtime-collaboration"],
|
||||||
"icon": "BookOpen",
|
"icon": "Compass",
|
||||||
"defaultOpen": false
|
"defaultOpen": false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,86 +1,61 @@
|
||||||
---
|
---
|
||||||
title: Prerequisites
|
title: Documentation
|
||||||
description: Required setup's before setting up SurfSense
|
description: Welcome to SurfSense's documentation
|
||||||
icon: ClipboardCheck
|
icon: BookOpen
|
||||||
---
|
---
|
||||||
|
|
||||||
|
import { Card, Cards } from 'fumadocs-ui/components/card';
|
||||||
|
import { ClipboardCheck, Download, Container, Wrench, Cable, BookOpen, FlaskConical, Heart } from 'lucide-react';
|
||||||
|
|
||||||
## Auth Setup
|
Welcome to **SurfSense's Documentation!** Here, you'll find everything you need to get the most out of SurfSense. Dive in to explore how SurfSense can be your AI-powered research companion.
|
||||||
|
|
||||||
SurfSense supports both Google OAuth and local email/password authentication. Google OAuth is optional - if you prefer local authentication, you can skip this section.
|
<Cards>
|
||||||
|
<Card
|
||||||
**Note**: Google OAuth setup is **required** in your `.env` files if you want to use the Gmail and Google Calendar connectors in SurfSense.
|
icon={<ClipboardCheck />}
|
||||||
|
title="Prerequisites"
|
||||||
To set up Google OAuth:
|
description="Required setup before installing SurfSense"
|
||||||
|
href="/docs/prerequisites"
|
||||||
1. Login to your [Google Developer Console](https://console.cloud.google.com/)
|
/>
|
||||||
2. Enable the required APIs:
|
<Card
|
||||||
- **People API** (required for basic Google OAuth)
|
icon={<Download />}
|
||||||

|
title="Installation"
|
||||||
3. Set up OAuth consent screen.
|
description="Choose your installation method"
|
||||||

|
href="/docs/installation"
|
||||||
4. Create OAuth client ID and secret.
|
/>
|
||||||

|
<Card
|
||||||
5. It should look like this.
|
icon={<Container />}
|
||||||

|
title="Docker Installation"
|
||||||
|
description="Deploy SurfSense with Docker Compose"
|
||||||
---
|
href="/docs/docker-installation"
|
||||||
|
/>
|
||||||
## File Upload's
|
<Card
|
||||||
|
icon={<Wrench />}
|
||||||
SurfSense supports three ETL (Extract, Transform, Load) services for converting files to LLM-friendly formats:
|
title="Manual Installation"
|
||||||
|
description="Set up SurfSense manually from source"
|
||||||
### Option 1: Unstructured
|
href="/docs/manual-installation"
|
||||||
|
/>
|
||||||
Files are converted using [Unstructured](https://github.com/Unstructured-IO/unstructured)
|
<Card
|
||||||
|
icon={<Cable />}
|
||||||
1. Get an Unstructured.io API key from [Unstructured Platform](https://platform.unstructured.io/)
|
title="Connectors"
|
||||||
2. You should be able to generate API keys once registered
|
description="Integrate with third-party services"
|
||||||

|
href="/docs/connectors"
|
||||||
|
/>
|
||||||
### Option 2: LlamaIndex (LlamaCloud)
|
<Card
|
||||||
|
icon={<BookOpen />}
|
||||||
Files are converted using [LlamaIndex](https://www.llamaindex.ai/) which offers 50+ file format support.
|
title="How-To Guides"
|
||||||
|
description="Step-by-step guides for common tasks"
|
||||||
1. Get a LlamaIndex API key from [LlamaCloud](https://cloud.llamaindex.ai/)
|
href="/docs/how-to"
|
||||||
2. Sign up for a LlamaCloud account to access their parsing services
|
/>
|
||||||
3. LlamaCloud provides enhanced parsing capabilities for complex documents
|
<Card
|
||||||
|
icon={<FlaskConical />}
|
||||||
### Option 3: Docling (Recommended for Privacy)
|
title="Testing"
|
||||||
|
description="Running and writing tests for SurfSense"
|
||||||
Files are processed locally using [Docling](https://github.com/DS4SD/docling) - IBM's open-source document parsing library.
|
href="/docs/testing"
|
||||||
|
/>
|
||||||
1. **No API key required** - all processing happens locally
|
<Card
|
||||||
2. **Privacy-focused** - documents never leave your system
|
icon={<Heart />}
|
||||||
3. **Supported formats**: PDF, Office documents (Word, Excel, PowerPoint), images (PNG, JPEG, TIFF, BMP, WebP), HTML, CSV, AsciiDoc
|
title="Code of Conduct"
|
||||||
4. **Enhanced features**: Advanced table detection, image extraction, and structured document parsing
|
description="Community guidelines and expectations"
|
||||||
5. **GPU acceleration** support for faster processing (when available)
|
href="/docs/code-of-conduct"
|
||||||
|
/>
|
||||||
**Note**: You only need to set up one of these services.
|
</Cards>
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## LLM Observability (Optional)
|
|
||||||
|
|
||||||
This is not required for SurfSense to work. But it is always a good idea to monitor LLM interactions. So we do not have those WTH moments.
|
|
||||||
|
|
||||||
1. Get a LangSmith API key from [smith.langchain.com](https://smith.langchain.com/)
|
|
||||||
2. This helps in observing SurfSense Researcher Agent.
|
|
||||||

|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Crawler
|
|
||||||
|
|
||||||
SurfSense have 2 options for saving webpages:
|
|
||||||
- [SurfSense Extension](https://github.com/MODSetter/SurfSense/tree/main/surfsense_browser_extension) (Overall better experience & ability to save private webpages, recommended)
|
|
||||||
- Crawler (If you want to save public webpages)
|
|
||||||
|
|
||||||
**NOTE:** SurfSense currently uses [Firecrawl.py](https://www.firecrawl.dev/) for web crawling. If you plan on using the crawler, you will need to create a Firecrawl account and get an API key.
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
Once you have all prerequisites in place, proceed to the [installation guide](/docs/installation) to set up SurfSense.
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ There are two ways to install SurfSense, but both require the repository to be c
|
||||||
|
|
||||||
This method provides a containerized environment with all dependencies pre-configured. Less Customization.
|
This method provides a containerized environment with all dependencies pre-configured. Less Customization.
|
||||||
|
|
||||||
[Learn more about Docker installation](/docs/docker-installation)
|
[Learn more about Docker installation](/docs/docker-installation/install-script)
|
||||||
|
|
||||||
## Manual Installation (Preferred)
|
## Manual Installation (Preferred)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,14 @@
|
||||||
"pages": [
|
"pages": [
|
||||||
"---Guides---",
|
"---Guides---",
|
||||||
"index",
|
"index",
|
||||||
|
"prerequisites",
|
||||||
"installation",
|
"installation",
|
||||||
"docker-installation",
|
|
||||||
"manual-installation",
|
"manual-installation",
|
||||||
|
"docker-installation",
|
||||||
"connectors",
|
"connectors",
|
||||||
"how-to",
|
"how-to",
|
||||||
"---Development---",
|
"---Developers---",
|
||||||
"testing"
|
"testing",
|
||||||
|
"code-of-conduct"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
86
surfsense_web/content/docs/prerequisites.mdx
Normal file
86
surfsense_web/content/docs/prerequisites.mdx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
---
|
||||||
|
title: Prerequisites
|
||||||
|
description: Required setup's before setting up SurfSense
|
||||||
|
icon: ClipboardCheck
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Auth Setup
|
||||||
|
|
||||||
|
SurfSense supports both Google OAuth and local email/password authentication. Google OAuth is optional - if you prefer local authentication, you can skip this section.
|
||||||
|
|
||||||
|
**Note**: Google OAuth setup is **required** in your `.env` files if you want to use the Gmail and Google Calendar connectors in SurfSense.
|
||||||
|
|
||||||
|
To set up Google OAuth:
|
||||||
|
|
||||||
|
1. Login to your [Google Developer Console](https://console.cloud.google.com/)
|
||||||
|
2. Enable the required APIs:
|
||||||
|
- **People API** (required for basic Google OAuth)
|
||||||
|

|
||||||
|
3. Set up OAuth consent screen.
|
||||||
|

|
||||||
|
4. Create OAuth client ID and secret.
|
||||||
|

|
||||||
|
5. It should look like this.
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Upload's
|
||||||
|
|
||||||
|
SurfSense supports three ETL (Extract, Transform, Load) services for converting files to LLM-friendly formats:
|
||||||
|
|
||||||
|
### Option 1: Unstructured
|
||||||
|
|
||||||
|
Files are converted using [Unstructured](https://github.com/Unstructured-IO/unstructured)
|
||||||
|
|
||||||
|
1. Get an Unstructured.io API key from [Unstructured Platform](https://platform.unstructured.io/)
|
||||||
|
2. You should be able to generate API keys once registered
|
||||||
|

|
||||||
|
|
||||||
|
### Option 2: LlamaIndex (LlamaCloud)
|
||||||
|
|
||||||
|
Files are converted using [LlamaIndex](https://www.llamaindex.ai/) which offers 50+ file format support.
|
||||||
|
|
||||||
|
1. Get a LlamaIndex API key from [LlamaCloud](https://cloud.llamaindex.ai/)
|
||||||
|
2. Sign up for a LlamaCloud account to access their parsing services
|
||||||
|
3. LlamaCloud provides enhanced parsing capabilities for complex documents
|
||||||
|
|
||||||
|
### Option 3: Docling (Recommended for Privacy)
|
||||||
|
|
||||||
|
Files are processed locally using [Docling](https://github.com/DS4SD/docling) - IBM's open-source document parsing library.
|
||||||
|
|
||||||
|
1. **No API key required** - all processing happens locally
|
||||||
|
2. **Privacy-focused** - documents never leave your system
|
||||||
|
3. **Supported formats**: PDF, Office documents (Word, Excel, PowerPoint), images (PNG, JPEG, TIFF, BMP, WebP), HTML, CSV, AsciiDoc
|
||||||
|
4. **Enhanced features**: Advanced table detection, image extraction, and structured document parsing
|
||||||
|
5. **GPU acceleration** support for faster processing (when available)
|
||||||
|
|
||||||
|
**Note**: You only need to set up one of these services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LLM Observability (Optional)
|
||||||
|
|
||||||
|
This is not required for SurfSense to work. But it is always a good idea to monitor LLM interactions. So we do not have those WTH moments.
|
||||||
|
|
||||||
|
1. Get a LangSmith API key from [smith.langchain.com](https://smith.langchain.com/)
|
||||||
|
2. This helps in observing SurfSense Researcher Agent.
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Crawler
|
||||||
|
|
||||||
|
SurfSense have 2 options for saving webpages:
|
||||||
|
- [SurfSense Extension](https://github.com/MODSetter/SurfSense/tree/main/surfsense_browser_extension) (Overall better experience & ability to save private webpages, recommended)
|
||||||
|
- Crawler (If you want to save public webpages)
|
||||||
|
|
||||||
|
**NOTE:** SurfSense currently uses [Firecrawl.py](https://www.firecrawl.dev/) for web crawling. If you plan on using the crawler, you will need to create a Firecrawl account and get an API key.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Once you have all prerequisites in place, proceed to the [installation guide](/docs/installation) to set up SurfSense.
|
||||||
|
|
@ -61,7 +61,7 @@ export function toDisplayDoc(item: ApiDocumentInput): DocumentDisplay {
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_TYPE_FILTER: DocumentTypeEnum[] = [];
|
const EMPTY_TYPE_FILTER: DocumentTypeEnum[] = [];
|
||||||
const INITIAL_PAGE_SIZE = 20;
|
const INITIAL_PAGE_SIZE = 50;
|
||||||
const SCROLL_PAGE_SIZE = 5;
|
const SCROLL_PAGE_SIZE = 5;
|
||||||
|
|
||||||
function isValidDocument(doc: DocumentElectric): boolean {
|
function isValidDocument(doc: DocumentElectric): boolean {
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export function useInbox(
|
||||||
searchSpaceId: number | null,
|
searchSpaceId: number | null,
|
||||||
category: NotificationCategory,
|
category: NotificationCategory,
|
||||||
prefetchedUnread?: { total_unread: number; recent_unread: number } | null,
|
prefetchedUnread?: { total_unread: number; recent_unread: number } | null,
|
||||||
prefetchedUnreadReady = true,
|
prefetchedUnreadReady = true
|
||||||
) {
|
) {
|
||||||
const electricClient = useElectricClient();
|
const electricClient = useElectricClient();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -156,9 +156,7 @@ class NotificationsApiService {
|
||||||
* Get unread counts for all categories in a single request.
|
* Get unread counts for all categories in a single request.
|
||||||
* Replaces 2 separate getUnreadCount calls (comments + status).
|
* Replaces 2 separate getUnreadCount calls (comments + status).
|
||||||
*/
|
*/
|
||||||
getBatchUnreadCounts = async (
|
getBatchUnreadCounts = async (searchSpaceId?: number): Promise<GetBatchUnreadCountResponse> => {
|
||||||
searchSpaceId?: number
|
|
||||||
): Promise<GetBatchUnreadCountResponse> => {
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (searchSpaceId !== undefined) {
|
if (searchSpaceId !== undefined) {
|
||||||
params.append("search_space_id", String(searchSpaceId));
|
params.append("search_space_id", String(searchSpaceId));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue